# Building From the project page, or `umtool build ` to see the chain. ## Four steps | # | step | why it is separate | |---|---|---| | 1 | **check every source is still fetchable** | `yt-dlp --simulate`, no bytes. See below. | | 2 | **resolve windows (DRY)** | Applying is a separate, explicit action. | | 3 | **build** | `--progress ndjson --continue-on-error` | | 4 | **verify the file that came out** | A build can exit 0 and be wrong. | **Availability is a STEP, not a preamble somebody remembers to run.** It is the one fact about a manifest that goes stale in *both* directions — a source can die after the manifest is written, and one annotated "gone" can come back. It costs seconds. Without it a dead source is discovered twenty minutes and a dozen paid-for fetches into the build. **Resolve runs dry.** A widener silently rewriting windows somebody just set in the bench is exactly the surprise `lock` exists to prevent, so the chain never passes `--write` and applying is a second action. **Verify exists because success is not self-evident.** A concat that produced a zero-length file, a chapter pass that dropped markers, a timeline that lost a clip because `--continue-on-error` let it — each looks like success at the terminal and like a finished video in a directory listing. `verify-build.mjs` checks duration > 0, chapters == timeline entries, and a length floor. ## Presets - **preview one clip** — `--only --no-xfade`, for after moving an edge. - **fast pass** — hard cuts over the whole timeline. Minutes, not tens of minutes. What you watch to check the argument. - **final** — crossfades and chapters. The deliverable. ## Options Under the preset, each mapping to one flag of `build-video.mjs`. "Show the command" prints the argv with them in it, which is the proof. | option | flag | what it does | chain | |---|---|---|---| | **variant** | `--variant sourced\|full` | which cut of a two-cut manifest; `full` writes `out/-full.mp4` | all four steps; the overwrite guard checks *that* variant's file | | **hard cuts** | `--no-xfade` | hard cuts on a preset that would crossfade | all four | | **chapters only** | `--chapters-only` | retitle the chapters from the segments already on disk — no fetch, no encode | build only, 5-minute cap; refuses when a segment is missing | | **rail preview** | `--preview ` | the rail alone over a window, to `.preview.mp4` | build only, 5-minute cap; no verify (nothing to verify) | | **re-render on-screen** | `--chrome-only` | re-lay the on-screen deck over the segments already on disk — re-probe, rewrite the schedule, recompose (re-render only if its cache key changed), re-concat with the overlay, re-mux the chapters | build only; skips the preflight and the dry resolve (nothing is fetched) but keeps the build's OWN timeout, not the 5-minute cap — a deck re-render is a render plus a concat of the whole cut | An unknown variant is a 400 before anything runs. `chaptersOnly` and `preview` skip the preflight and the dry resolve because neither touches a source. ## Timeouts Per step, not one number. The default is 15 minutes and exists to catch the accidental hour-long job; a 19-clip crossfaded build legitimately runs 20 to 40, so the build step asks for `max(15 min, clips × 90 s)`. Raising the default to fit the build would remove the guard from everything else. ## Cancelling is safe, and resuming is free Cancel kills the **process group**. `build-video.mjs` shells out through `execFile`, so the thing actually burning CPU or holding a download open is a *grandchild* — `child.kill()` reaps the node process and leaves it running, which is the same failure the diarize backfill had. Every artefact is content-addressed: a fetched window by its window, a segment by its clip id. Re-running skips whatever finished. **A cancelled build is a paused one.** ## Overwriting `build-video.mjs` always passes `-y`. An output **newer than its manifest** is refused (409, `needsReplace`); with `replace=1` it is moved aside as `out/..mp4` — the stamp shape `promote` already uses for a demoted cut. A deliverable that cost an hour of network fetches is never destroyed to make a new one. ## One job, process-wide Two builds writing one `out/segments/` would interleave, and two in different projects would still fight over yt-dlp's rate limits and the CPU. A second start is a 409 naming what is running. ## Progress `--progress ndjson` emits one JSON object per line: `start`, `card`, `clip`, `fetch`, `snap`, `segment`, `entry-failed`, `concat`, `chapters`, `note`, `done`, `error`. The UI renders one box per timeline entry from them, because "step 3 of 4, running" is not progress when step 3 is the twenty-minute one. The event set is exactly what was already being printed. Making it a *format* switch is what stops a wording change from breaking the driver. ## `--continue-on-error` A dead source at clip 14 of 19 otherwise throws away thirteen fetches already paid for. With it, everything buildable is built — and the run then **refuses to concatenate** and exits non-zero. A finished file quietly missing a citation looks complete, which is worse than no file. ## Why it is spawned, not imported A 40-minute chain of yt-dlp and ffmpeg inside a request handler has no cancellation story, its `execFile` buffers live in the server's heap, and a runaway grandchild outlives the request that started it. `buildVideo()` is exported anyway, and `widen()` *is* imported — the bench needs the CLI's own function, or the two would disagree about where a clip ends. `umtool build` **prints** the chain rather than running it, for the same reason: cancellation, the timeouts and the group kill live in the server's job runner, and a second runner would be a second, worse set of those. ## Everything here is testable offline The e2e fixture writes stub `YTDLP_BIN` and `QRENCODE_BIN`. The stub reports one id removed the way a deleted upload is, which gives `source-unavailable` a true answer. A full 4-clip build — cache reuse, three stub fetches, QR overlay, concat, chapters, verify — runs in **3 seconds with no network**. ## See also [quirks.md](quirks.md) for the VP9 trap, `--ignore-config`, exit 101, the Rumble HLS retry and the relative silence threshold — every one of which will bite a build and none of which is guessable.