> ## Documentation Index
> Fetch the complete documentation index at: https://docs.decktalk.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Build in CI and offline

> Build and check a DeckTalk video on every push, or on a machine with no network.

A build without voice needs no API key, and after `decktalk install` it needs no network. Use this guide to build and
check the video on every push, or on a machine that is offline.

`decktalk build` can pass when `decktalk verify` fails, because the build's own verify stage does not
measure the cues. To gate a job on the video, run both commands.

```console theme={null}
decktalk build --no-voice && decktalk verify
```

## Add a GitHub Actions job

This job builds the video with no API key, checks it, and uploads the video and the verify results.

```yaml .github/workflows/video.yml theme={null}
jobs:
  video:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v10.1.0
      - run: uv tool install decktalk
      - name: Cache Chromium and ffmpeg
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/ms-playwright
            ~/.cache/decktalk
          key: browsers-${{ runner.os }}
      - run: decktalk install
      - run: decktalk doctor
      - run: decktalk build --no-voice
        env:
          DECKTALK_VIDEO_PRESET: veryfast
      - run: decktalk verify --json > verify.json
      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: video
          path: build/out/*.mp4
      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: verify
          path: verify.json
```

* The cache paths are for Linux. [Requirements](/requirements#what-install-downloads) lists the paths on macOS and
  Windows.
* On Linux, `install` installs Chromium's system libraries, which needs sudo or root.
  [Linux system libraries](/requirements#linux-system-libraries) gives the full rule.
* `install` downloads ffmpeg into DeckTalk's own cache, beside Chromium, so the one cache entry above holds both.
* If a proxy blocks the Chromium download, set `PLAYWRIGHT_DOWNLOAD_HOST` to a mirror.

## What each command catches

Each command exits 1 on a different set of problems.

| Command              | Exits 1 on                                                                                                                                                 | Does not catch                                                                                                                             |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `decktalk build`     | `PAGE ERROR`, `BLACK`, `SPEECH AT CUT`                                                                                                                     | `TRUNCATED`, `STALLED`, `NO COVER`, `BLACK?`, `SPOKEN SYMBOL?`, every cue result                                                           |
| `decktalk align`     | `UNRESOLVED`, `UNCUED ELEMENT`, an unknown cue id                                                                                                          | anything about the pages or the video                                                                                                      |
| `decktalk record`    | `PAGE ERROR`, `STALLED`, `TRUNCATED`, `NO COVER`, `KATEX ERROR`, `KATEX NOT LOADED`, `CDN ASSET`                                                           | `BLACK?`, unless you pass `--strict`                                                                                                       |
| `decktalk verify`    | `BLACK`, `SPEECH AT CUT`, `OFF CUE`, `NO CHANGE`, `UNRESOLVED`                                                                                             | a `skipped` row, such as a cue marked `"verify": false`                                                                                    |
| `decktalk status`    | a missing or unreadable input file, and two input files that disagree                                                                                      | anything the build wrote                                                                                                                   |
| `decktalk doctor`    | a missing tool                                                                                                                                             | nothing about the project itself                                                                                                           |
| `decktalk preflight` | an unfilled placeholder, `UNRESOLVED`, `UNCUED ELEMENT`, `OFF STAGE`, and `PAGE ERROR`, `NO CHANGE` or `POP AT CUT` in frozen frames, before any recording | motion, timing, and every check on the final mp4. `IN CAPTION BAND?` and `CUES OVERLAP?` are reported and cost exit 1 only with `--strict` |

A cue phrase that is not found and a cue id that no page carries stop `build` as errors rather than
findings, so those two exit 3 there. `decktalk align` reports both as findings and exits 1.

A build without voice puts a click at every word start, so `verify` also reports an
[a/v value](/reference/verify#the-a/v-value) for each cue. The job fails when an a/v value is past
`max_av_frames`, which is three frames at the defaults. A build without voice therefore tests the recording, the cut,
and the mix, not only the cues.

`build --strict` stops on a missing clip, unless the clip section sets `optional = true`. That one is an error
rather than a finding, so it exits 3. With `--no-voice`, a build cannot fail on loudness at all, because a build
without voice skips the loudness pass.

## Fail the job on a bad video

Every command shares one exit rule. Use the exit codes as the assertions of a job.

| Code | What it means                                                                                                                                                                     |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0    | The command ran and found nothing, or `--exit-zero` was passed.                                                                                                                   |
| 1    | The command found something. A certain finding, such as `OFF CUE` or `TRUNCATED`, always does this. An uncertain finding, whose label ends in `?`, does it only under `--strict`. |
| 2    | A usage error, such as an unknown flag.                                                                                                                                           |
| 3    | DeckTalk itself could not run. A missing input file, a malformed `decktalk.toml` or a provider that refused does this.                                                            |
| 130  | The run was interrupted.                                                                                                                                                          |

A non-zero exit with `error` null in the JSON is a finding the job can fix. A non-null `error.code`
is an error, and the job stops.

```console theme={null}
decktalk record --strict     # also fail on BLACK?
decktalk verify              # fail on any certain finding
```

`status` and `doctor` take `--exit-zero` and refuse `--strict`, because every finding they report is
certain and the flag could change nothing.

To keep the job running and decide later, add `--exit-zero` and read the JSON.

```console theme={null}
decktalk verify --json --exit-zero > verify.json
jq -e '.ok' verify.json
jq '.verify.cues[] | select(.verdict.code != "CHANGED")' verify.json
jq '.findings.items[]' verify.json
```

`--exit-zero` makes the command exit 0 on findings, but an error still exits 3. The JSON `ok` is false when the
command would exit non-zero without `--exit-zero`. `jq -e` then exits 1, the second `jq` line lists the cue rows
to look at, and the third lists every finding of the run in the shape every command shares.
[The `--json` envelope](/reference/cli#the-json-envelope) lists every key.

## Allow for slower runners

A hosted runner has no GPU that Chromium can use, so Chromium draws every frame in software. The macOS and Windows
runners are slower than Linux.

* A page renders more slowly on a runner than on a laptop.
* A canvas figure or a large SVG renders more slowly still.
* A reveal can start two or three frames late, and `verify` reports `OFF CUE`.
* A long draw after narration t=0 can read `STALLED`.

If that happens only on a slower runner, widen the limits for that runner. Keep the defaults on the machine that
records the published video. These three variables are the ones a slow runner needs, and the values on the right
are a starting point rather than a rule.

| Environment variable                | Default | A slower runner |
| ----------------------------------- | ------- | --------------- |
| `DECKTALK_VERIFY_MAX_OFFSET_FRAMES` | 2       | 4               |
| `DECKTALK_VERIFY_MAX_AV_FRAMES`     | 3       | 5               |
| `DECKTALK_RECORD_STALL_MS`          | 150     | 400             |

A reveal can also start late while a large layer moves, such as a canvas figure that pans. To gate a job on cue
timing, verify a light deck whose reveals land on still pages, and report the busy deck without failing. DeckTalk's
own test builds `tests/e2e/fixture`, a five-section still deck, with `pytest -m e2e`. It decides from the platform
it is running on: it fails on a cue offset over the limit on Linux, and on macOS and Windows it prints the largest
offset it saw and asserts a wider bound instead. Those two platforms run on every push to `main` and before a
release is published.

```yaml .github/workflows/video.yml theme={null}
      - name: Widen the sync tolerance on the slower runners
        if: runner.os != 'Linux'
        run: |
          echo "DECKTALK_VERIFY_MAX_OFFSET_FRAMES=4" >> "$GITHUB_ENV"
          echo "DECKTALK_VERIFY_MAX_AV_FRAMES=5" >> "$GITHUB_ENV"
          echo "DECKTALK_RECORD_STALL_MS=400" >> "$GITHUB_ENV"
```

## Build a voiced video in CI

Put `ELEVENLABS_API_KEY` and `ELEVENLABS_VOICE_ID` in the job's secrets, and export them to the build step.
DeckTalk never prints either value.

<Warning>A build without voice refuses to run in a project whose take index holds paid takes for the sections it was
asked to write, because replacing those rows would make the next voiced build pay for them again. The refusal names
the sections that are free to rehearse. Give a CI job that builds without voice its own checkout, or its own
`build/` directory.</Warning>

[A build without voice and a voiced build](/guides/rebuild-one-section#a-build-without-voice-and-a-voiced-build) explains why.

## Set the project directory

Every project command takes `--project DIR`. The `DECKTALK_PROJECT` variable sets the default, so a job can run
from the repository root.

```console theme={null}
DECKTALK_PROJECT=lessons/derivatives decktalk build --no-voice
```

## Build offline

A build without voice runs with no network after these steps.

<Steps>
  <Step title="Download the tools">
    On a machine with network, run `install`. It downloads Chromium and ffmpeg.

    ```console theme={null}
    decktalk install
    ```
  </Step>

  <Step title="Create the project">
    Run `init` after `install`. `init` copies the packaged KaTeX into `deck/katex/`.

    ```console theme={null}
    decktalk init my-lesson
    ```
  </Step>

  <Step title="Build the video">
    Run a build without voice in the project directory.

    ```console theme={null}
    decktalk build --no-voice
    ```
  </Step>
</Steps>

In the starter, `deck/index.html` loads KaTeX from `deck/katex/`, before the runtime. A page of your own
that points its KaTeX tags anywhere else leaves its equations as plain text offline, and `record` reports
`KATEX NOT LOADED`, which fails the command. Point the tags at the copy beside the page.

```html deck/index.html theme={null}
<link rel="stylesheet" href="./katex/katex.min.css">
<script src="./katex/katex.min.js"></script>
```

## Next

* **See every exit code:** [CLI](/reference/cli#exit-codes)
* **Fix a failed check:** [Troubleshooting](/help/troubleshooting)
* **Understand a cue result:** [Verify](/reference/verify#results)
