@aicopycoders/exodus 2026.9.1400 → 2026.9.1700-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,532 @@
1
+ ---
2
+ name: exodus-video
3
+ description: Make a video ad with Exodus from the terminal. The main path is script-first: run a saved video workflow with `npx @aicopycoders/exodus workflow run`, no Show involved. Starting from a Show with `exodus video start --show` is the other way in. Either way you read and approve the storyboard, pull every finished piece (per-scene clips, the narration track and per-scene voice, keyframes, cast identity stills, word timings, the music bed) into a folder with `exodus video pull`, and upload a finished cut for approval. Exodus makes the pieces; the cut is made outside it, in whatever editor the user wants — this skill hands over the pieces and the facts about them, it does not prescribe an editor. Use it whenever the user has invoked Exodus and wants anything to do with a video ad, an ad run's clips or storyboard, a Show, "pull the pieces", "stitch it together", "make the cut", "upload my cut", or checking on a video run ("exodus, make a video ad from this script", "exodus, is my video run done", "exodus, pull the clips for run X and cut them", "exodus, what's wrong with scene 3", "exodus video status"). Also use it when the user ran an `npx @aicopycoders/exodus video` command or the `exodus` hub skill routed here. Video is admin-only: "video isn't enabled for this key" means either the dashboard user lacks the admin role or the run id is wrong, so check the id and then say so; do not retry. Never claim generic video-editing asks ("edit this mp4", "add captions to my reel") without Exodus context; in shared folders those belong to the user's other tools. Running or authoring workflows generally is `exodus-workflow`; static image ads are `exodus-image`; copy is `exodus-write`.
4
+ ---
5
+
6
+ # Video: make the pieces, cut the ad, hand it back
7
+
8
+ Exodus makes the PIECES of a video ad, not the finished ad. From a script, a run
9
+ writes a storyboard, draws one picture per scene, records the voice, renders one
10
+ video clip per scene, and generates a music bed. Then it parks and waits for a
11
+ cut.
12
+
13
+ There is no server-side stitch and no server-side mix. Nothing joins the clips,
14
+ nothing sets levels, nothing burns in captions
15
+ (`scout/src/workflow/video/music-bed.ts` states the ruling: "the cut, and the
16
+ mix, happen outside from the pieces the CLI pulls"). You pull the pieces, cut
17
+ them wherever you like, and upload the result. Approving the upload is what
18
+ makes it the ad.
19
+
20
+ Your job in this skill is to drive that loop, hand over the pieces with the
21
+ facts about them, and make whatever cut the user asked for. **How to edit is the
22
+ user's call, not this skill's** — see "Cutting the ad".
23
+
24
+ ## Two routes in, one set of pieces out
25
+
26
+ **The workflow route (no Show) is the main path.** The ad is a saved workflow in
27
+ the brand, started script-first with `exodus workflow run`. This is where the
28
+ real work happens and what to assume unless the user says otherwise.
29
+
30
+ **The Show route** starts from a locked Show — its cast, rooms and voices set up
31
+ on the dashboard beforehand — with `exodus video start --show`. It buys two
32
+ things the workflow route does not have: `video flag` for sending a storyboard
33
+ back with a note, and script-turn edits at the gate.
34
+
35
+ Everything after the storyboard is the same on both: the same nodes render, the
36
+ same `video status` reads the run, the same `video pull` writes the same folder,
37
+ the same `video upload` and `approve` finish it. Where a behaviour differs, this
38
+ skill says which route it belongs to. **Do not assume the routes are symmetric**
39
+ — see "What each route can and cannot do".
40
+
41
+ ## Before anything
42
+
43
+ - `npx @aicopycoders/exodus video --help` and `workflow --help` are the
44
+ authoritative flag lists. The `video` verb is admin-only and hidden from the
45
+ top-level `--help`; that is expected, not a broken install.
46
+ - **Video is admin-only on both routes**, and says so two different ways.
47
+ - "video isn't enabled for this key", from a `video` verb, has two causes the
48
+ CLI cannot tell apart: the user is not an admin on this brand, or the run id
49
+ does not exist. Check the id first; if it is right, they need the admin role.
50
+ - "Video workflows aren't available on your account. Ask an admin if you need
51
+ video.", from `workflow run`, is unambiguous — `startRun` refuses any graph
52
+ containing a video node for a non-admin (`convex/workflows.ts:6471`).
53
+
54
+ No flag or retry fixes either one.
55
+ - Other failures: `npx @aicopycoders/exodus doctor` first, then follow what it prints.
56
+ - `ffprobe` on the PATH lets `upload` read the cut's length by itself. Without
57
+ it, `upload` falls back to parsing an MP4's own header, and for anything else
58
+ you must pass `--duration <sec>`. Editing tools have their own requirements on
59
+ top of that — the bundled script needs a full `ffmpeg` with `libx264` and
60
+ exits 2 if it is missing.
61
+ - The brand matters. A workflow and a Show both belong to one brand, so
62
+ `exodus workflow list` and `exodus video shows` show the active brand's only.
63
+ Wrong list means wrong brand; `exodus brand use <slug>`.
64
+
65
+ ## The loop
66
+
67
+ Run each line as `npx @aicopycoders/exodus …`; `exodus` below is shorthand.
68
+
69
+ **Starting on the workflow route (the main path).** The workflow is already
70
+ saved in the brand — find it and read what it wants before you run it, because
71
+ the script's input field is named by the workflow, not by this skill.
72
+
73
+ ```operator-guide
74
+ exodus workflow list the brand's workflows
75
+ exodus workflow describe <workflowId|name> what inputs it needs
76
+ exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
77
+ start, stop at the storyboard gate
78
+ exodus video status <runId> where it is, per scene
79
+ ```
80
+
81
+ **Starting on the Show route.**
82
+
83
+ ```operator-guide
84
+ exodus video shows which Shows are ready
85
+ exodus video start --show <id> --script script.txt --wait start, wait for the storyboard gate
86
+ ```
87
+
88
+ **From the storyboard on, both routes are the same.**
89
+
90
+ ```operator-guide
91
+ exodus video storyboard <runId> read the scene cards
92
+ exodus video approve <runId> keep going
93
+ exodus video flag <runId> --note "what's wrong" send it back — SHOW RUNS ONLY
94
+ exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
95
+ exodus video status <runId> where it is, per scene
96
+ exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
97
+ … make the cut (your choice of tool)
98
+ exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
99
+ exodus video approve <runId> make it the ad
100
+ ```
101
+
102
+ Every command takes `--json`. Read `--json` when you need to decide something
103
+ from the output; read the plain form when you are relaying it to the user.
104
+
105
+ **Read a run's position with `exodus video status`, on either route.** It is the
106
+ richest reader — every scene's clip, voice and picture, with the findings. A
107
+ `--wait` that landed already printed the same stop block, and `workflow inbox`
108
+ lists a parked video run (its row carries `hasShow`), but neither shows the
109
+ per-scene detail, and the checkpoint verbs cannot resolve a video park — see
110
+ "What each route can and cannot do".
111
+
112
+ **Where a run can stop.** Five stops, each with the exact next command printed
113
+ under it:
114
+
115
+ - storyboard gate: needs `approve`, or `flag` on a Show run.
116
+ - final watch: every piece is made; needs `pull`, a cut, and `upload`. A
117
+ workflow run only reaches this park if its video node sets `finalWatch: true`
118
+ — otherwise it skips straight to finished.
119
+ - paused: a builder checkpoint neither video verb resolves —
120
+ `exodus workflow checkpoint <runId>` is the verb for that one.
121
+ - failed: `status` shows how far it got, and a `--wait` that was running exits
122
+ 1 with the error. Tell the user; do not start another run on your own.
123
+ - finished: pull what it made.
124
+
125
+ **`--wait` stops at both video parks on either route** (#1818). `workflow run
126
+ --wait` and `video start --wait` both end at the storyboard gate and again at
127
+ the final watch, exit 0, and close with the same `Parked:` block and next
128
+ commands that `exodus video status` prints. Under `--json` the line carries an
129
+ additive `stop` field in the same shape `video status --json` uses, so branch on
130
+ `stop.at` rather than re-deriving the park. Every other park still prints its
131
+ notice once and keeps polling, so a resolve from another shell carries the run
132
+ on — a Checkpoint park is `exodus workflow checkpoint <runId>`, not a video verb.
133
+
134
+ `exodus video status` prints these stop lines any time, so an agent that lost a
135
+ `--wait` terminal picks up from `status` alone. `--wait` gives up after 60
136
+ minutes with "Still running after 60 minutes" and exit 0; that is not done, so
137
+ check `status` before treating it as finished. Do not poll `status` in a tight
138
+ loop; a full run is several minutes.
139
+
140
+ **What costs money.** Starting the run creates the storyboard. Approving the
141
+ storyboard is the cost gate: it releases the paid rendering of every picture,
142
+ voice and clip. `shows`, `workflow list`, `workflow describe`, `video status`,
143
+ `storyboard` and `pull` are free reads; run them as often as you like. Never
144
+ start a second run to "retry" without the user asking; flag the storyboard (Show
145
+ runs) or tell them what failed instead.
146
+
147
+ **`--auto-approve` does not cover either video gate.** It releases Checkpoint
148
+ boxes only (`convex/workflows.ts:7266` keys on `pauseReason === "checkpoint"`),
149
+ and the storyboard gate and the final watch carry no pause reason. An unattended
150
+ `workflow run --auto-approve` still stops dead at the cost gate — which is the
151
+ safe behaviour, since nothing should approve paid rendering or ship an uncut ad
152
+ on its own.
153
+
154
+ ## What each route can and cannot do
155
+
156
+ Verified against the routes, not assumed. Everything here is admin-gated on top.
157
+
158
+ | Verb | Workflow run (no Show) | Show run |
159
+ |---|---|---|
160
+ | `video shows` | nothing to list | lists the brand's Shows |
161
+ | `video start --show` | not the entry point | starts the run |
162
+ | `workflow run` | starts the run | not the entry point |
163
+ | `video status` | works | works |
164
+ | `video storyboard` | works | works |
165
+ | `video approve` | works, both gates | works, both gates |
166
+ | `video flag --note` | **refused** | works |
167
+ | script-turn edits at the gate | **refused** | dashboard only |
168
+ | `video retry-frame` | works | works |
169
+ | `video pull` | works | works |
170
+ | `video upload` | only if the video node sets `finalWatch: true` | works |
171
+ | `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
172
+
173
+ Five consequences worth knowing before you promise a user anything:
174
+
175
+ - **A workflow only stops for your cut if its video node asks to.** `finalWatch`
176
+ defaults to `false` (`convex/lib/workflow/catalog.ts:780`), and the run only
177
+ parks for a final watch when it is `true`. Without it the run renders every
178
+ piece and runs straight to the end, and `video upload` answers "This ad isn't
179
+ waiting for your final cut right now." You can still `pull` the pieces and cut
180
+ them, but there is nothing to attach the cut to. **Check this before you
181
+ promise a user an upload**, with `exodus workflow export <workflowId|name>`:
182
+ the export carries every node's `config` verbatim, so look for the `video`
183
+ node and a `finalWatch: true` under it. The key is optional, so *absent* means
184
+ off just as much as `false` does. `exodus workflow describe` does NOT answer
185
+ this — it returns only name, inputs, prerequisites and outputs, no node
186
+ config. If `finalWatch` is off the workflow needs editing (`exodus-workflow`);
187
+ no flag on `workflow run` turns it on.
188
+ - **`exodus video approve` is the verb at a video gate, not the checkpoint
189
+ cluster.** `workflow checkpoint approve|edit|retry` preflight on
190
+ `pauseReason === "checkpoint"` (`exodus/commands/workflow.ts:4487-4519`), and
191
+ both video gates park with no pause reason at all, so those verbs refuse
192
+ client-side with "Run … is not parked for a checkpoint approval — it is parked
193
+ at the cost gate (legacy)." `exodus video approve` posts straight through and
194
+ works on either route. `exodus workflow cancel` also works at a video gate; it
195
+ has no preflight.
196
+
197
+ - **`flag` is Show-only and fails loudly.** `exodus video flag` on a workflow run
198
+ answers "Flag-with-note is only available on Video-module ads."
199
+ (`convex/videoModule.ts:2208`, guarded on `!run.showId`). Script-turn edits
200
+ refuse the same way (`:2334`). There is **no** CLI path that sends a workflow
201
+ run's storyboard back with a note — the direct-edit mutation exists but is
202
+ wired only to the dashboard's gate screen, not to any API route. From the
203
+ terminal the storyboard is approve-or-cancel: `exodus workflow cancel <runId>`
204
+ and run again with a better script, or edit the workflow. Do not offer `flag`
205
+ to a user on the workflow route.
206
+ - **`--wait` lands on both video parks, but only those.** Since #1818 every
207
+ `--wait` caller — `workflow run`, `triggers fire`, `checkpoint approve|retry`,
208
+ `repair retry|skip` — ends at a storyboard gate or a final watch, exits 0, and
209
+ prints the same `Parked:` block `video status` does
210
+ (`videoParkStop`, `exodus/commands/workflow.ts`). A Checkpoint, repair or
211
+ slots park is still not a landing: it prints its notice once and the loop
212
+ keeps polling, so a resolve from another shell carries the run on. Do not read
213
+ a park banner mid-poll as the wait having finished.
214
+ - **The run's page link differs.** A Show run's `dashboardUrl` opens
215
+ `/video?show=…&ad=…`; a showless run gets `/runs/<runId>`, because the `/video`
216
+ page cannot render one (`convex/http.ts:9204-9211`). The manifest's
217
+ `dashboardUrl` is always `/video?ad=<runId>`, which is the Show-route shape —
218
+ on a workflow run, hand the user the link `upload` prints, not the manifest's.
219
+
220
+ ## The script file
221
+
222
+ Plain text. A `CAST:` block naming the speakers, one line per turn, then a
223
+ `CTA:` block. On a Show run the names must match what the Show locked. The
224
+ planner sizes every line at
225
+ 2.5 words per second (`WORD_LADDER_WPS`), so a turn near 14 words plans a clip
226
+ near 6 seconds. If a line does get truncated in its clip, the manifest says so
227
+ (`speech-cutoff`).
228
+
229
+ ```
230
+ CAST:
231
+ THE EYES — a pair of tired cartoon eyeballs who stare at screens all day
232
+ THE SOFTGEL — a cheerful little capsule who knows how to fix that
233
+
234
+ THE EYES: Ten hours of screens today. Everything looks blurry and dry.
235
+ THE SOFTGEL: That's screen strain. Your eyes are begging for a break.
236
+
237
+ CTA:
238
+ THE SOFTGEL: Tap below and give your eyes ClearBlink.
239
+ ```
240
+
241
+ ## Reading the storyboard gate
242
+
243
+ `storyboard <runId>` prints one card per scene on both routes: who speaks, the
244
+ line, the planned duration, and whether its picture is drawn. Read it for the
245
+ user, then ask one question.
246
+
247
+ On a **workflow run** the question is approve or don't — there is no flag. If
248
+ the storyboard is wrong, say so plainly and let the user decide between
249
+ approving anyway, cancelling (`exodus workflow cancel <runId>`) and re-running
250
+ with a better script, or changing the workflow. Approving is the cost gate, so
251
+ do not approve a storyboard you have just told the user is wrong.
252
+
253
+ On a **Show run** you can also send it back: `video flag <runId> --note "…"`.
254
+ The note is read by the model that rewrites the storyboard, so make it concrete
255
+ ("scene 2 should be a close-up of the softgel, not the eyes").
256
+
257
+ Either way, do not approve on the user's behalf unless they told you to run the
258
+ whole loop unattended.
259
+
260
+ ## The handover: what `pull` gives you
261
+
262
+ `pull` writes a flat folder and a `manifest.json` that indexes it. The manifest
263
+ is the data you decide from; the filenames are its values. Scene numbers are the
264
+ run's own. Every `pull` re-downloads and overwrites every file, so running it
265
+ again is always safe.
266
+
267
+ ```
268
+ storyboard.json the whole storyboard envelope (see below)
269
+ reference.<ext> the reference still the scene pictures were drawn from
270
+ cast-<characterId>.<ext> one identity still per locked cast member
271
+ cast-ref-NN.<ext> an identity still the cast lock does not claim
272
+ scene-NN.keyframe.<ext> the picture scene NN's clip was animated from
273
+ scene-NN.<ext> the clip for scene NN, usually .mp4
274
+ scene-NN.voice.<ext> the voice track for scene NN, when one was delivered
275
+ scene-NN.words.json [{w, s, e}] word timings for that scene's speech
276
+ narration.mp3 one continuous narrator take of the whole script
277
+ narration.json that take's settings, master-timeline words, and the
278
+ per-scene ranges it was cut into
279
+ music.<ext> the music bed
280
+ manifest.json the index below
281
+ ```
282
+
283
+ `manifest.json`:
284
+
285
+ ```
286
+ runId, pulledAt, dashboardUrl the run and its page (/video?ad=<runId>)
287
+ storyboard, reference, music filenames, or null when not delivered
288
+ narration { file, timing }, or null on a per-scene run
289
+ cast[] one per identity still
290
+ characterId, name from the run's cast lock; null for an
291
+ unclaimed ledger still
292
+ file, status, error
293
+ scenes[] one per scene, in order
294
+ sceneIndex, durationSec the run's number and the clip's real length
295
+ clip, voice, keyframe, words filenames, or null
296
+ wordsFrom clip | voice — which file the word times are
297
+ measured against; null when none came
298
+ clipStatus done | failed | running | pending | missing
299
+ error why the clip failed, when it did
300
+ flagged, findings[] QC verdict: {check, code, severity: fail|warn, detail}
301
+ keyframeFindings[] the same, from the picture check; null when
302
+ nobody checked the picture
303
+ qc {verdict, attempts} from the renderer
304
+ revoiced the cast's pinned voice replaced the clip's
305
+ generated voice; null when there is no clip
306
+ speechTrimmed the clip was re-cut to its spoken words
307
+ (0.5 s before the first, 0.8 s after the
308
+ last); null when there is no clip
309
+ failed[] files that did not download: {file, url, error}
310
+ ```
311
+
312
+ ### What each piece is
313
+
314
+ **The clips** (`scene-NN.<ext>`). The finished take, already trimmed and
315
+ revoiced where the run did that — not a raw render you have to repair.
316
+ `durationSec` is its real measured length. Whether the clip carries sound
317
+ depends on the scene; see "Which scenes carry their voice where" below.
318
+
319
+ **The storyboard** (`storyboard.json`). The intended cut, in the planner's own
320
+ terms. `script[]` is the ad's spoken words in order — `{lineId, speakerId, text}`,
321
+ with `speakerId: null` meaning narrator VO. Each scene carries `sceneIndex`,
322
+ `durationSec`, `dialogue[]`, `voText`, `notes`, and `lineIds` (the script lines
323
+ that scene renders). A scene with `kind: "cutaway"` carries `cueLineId` instead:
324
+ it is a picture-only takeover of the frame while the spoken track underneath
325
+ keeps running, so it adds no audio and takes no place in the running order. The
326
+ envelope also carries `overlays[]` (each with a `cueLineId` and a `placement`)
327
+ and `audio` (`roomTone`, `music`, `sfxCues[]`) — the planner's notes on what
328
+ should sit on top. **Route difference:** on a Show run the image and motion
329
+ prompts (`framePrompt`, `videoPrompt`, `referencePrompt`) are stripped before
330
+ the file reaches you, for everyone including admins — they are style-pack IP
331
+ (`convex/workflows.ts:13847`, `isModuleRun = !!run.showId`). A workflow run has
332
+ no `showId`, so an admin key gets them in full. Their absence on a Show run is
333
+ by design, not a broken pull.
334
+
335
+ **The narration track** (`narration.mp3` + `narration.json`). Present when the
336
+ run recorded continuously: every narrated scene's `voText` joined into one
337
+ script and read by ElevenLabs in a single pass at speed 1.15 — one take, one
338
+ pace, start to finish. `narration.json` carries `takeHash`, `voiceId`,
339
+ `modelId`, `speed`, `alignment: "elevenlabs-timestamps"`, the master's word
340
+ timings, and `cuts[]`: for each scene, `startSec` and `durationSec` **into
341
+ narration.mp3**. Runs go per-scene instead when any scene has on-camera
342
+ dialogue, when the voiceover node is configured `take: "per-scene"`, or on an
343
+ older storyboard carrying no `script[]`; then there is no `narration.mp3` and
344
+ each scene's voice is its own recording. That choice is made from the storyboard
345
+ and the node config (`planNarration`), not from the route — a workflow run and a
346
+ Show run reach it the same way.
347
+
348
+ **The per-scene voice tracks** (`scene-NN.voice.<ext>`). On a continuous run
349
+ these are cut out of `narration.mp3` itself — the same audio, sliced at the
350
+ ranges in `narration.json` with a 0.2 s pad and a boundary at the midpoint of
351
+ each gap. They are not separate performances. On a per-scene run each one is its
352
+ own ElevenLabs render.
353
+
354
+ **Which scenes carry their voice where.** A scene whose storyboard entry has a
355
+ non-empty `voText` is a narrator scene: its clip is rendered silent and the
356
+ voice arrives as a separate track. A scene with on-camera `dialogue[]` has the
357
+ speech inside the clip — under the default voice path the video model's invented
358
+ voice is then replaced, in place, with the cast member's pinned ElevenLabs voice
359
+ (that is `revoiced: true`). Other voice paths leave the render owning its own
360
+ audio, e.g. lip-sync retargeting a VO track onto the video. The path is frozen
361
+ on the run — `--voice-path` on `video start` sets it on a Show run, the node
362
+ config sets it on a workflow run. Read `wordsFrom` and `revoiced` per scene
363
+ rather than assuming.
364
+
365
+ **Word timings** (`scene-NN.words.json`). Seconds from the start of the file
366
+ `wordsFrom` names, always. `wordsFrom: "clip"` is an ElevenLabs Scribe
367
+ (`scribe_v1`) transcription of that clip's own audio, taken blind — the model
368
+ never sees the script — and re-based if the clip was speech-trimmed.
369
+ `wordsFrom: "voice"` is the ElevenLabs alignment of the continuous master,
370
+ re-based to the start of that scene's cut; on a per-scene run it is instead a
371
+ blind Scribe pass over that scene's own voice track. Nothing is aligned to the
372
+ finished ad — that timeline does not exist until you make it.
373
+
374
+ **Cast identity stills** (`cast-*.<ext>`). A full-body identity still per cast
375
+ member, on a plain neutral background in the run's style, minted once and
376
+ frozen onto the run as its cast lock. Every scene picture was anchored to these,
377
+ which is why the same character holds across scenes. `cast-<characterId>` is a
378
+ locked member; `cast-ref-NN` is a still from the same minting the lock does not
379
+ claim. Useful as reference when you cut, and as the thing to point at when a QC
380
+ finding says `wrong-character`.
381
+
382
+ **The music bed** (`music.<ext>`). One continuous track for the whole ad,
383
+ generated by Eleven Music after the last clip lands, at the landed clips' summed
384
+ length plus a 3-second safety pad. One track per ad, never per-scene. It is
385
+ never mixed into any clip and never levelled — gain, fade and ducking are the
386
+ cut-maker's decisions. `--no-music` on `start` means no artifact at all, and a
387
+ music failure degrades to a warning rather than blocking the run.
388
+
389
+ ### Reading it before you cut
390
+
391
+ - `clipStatus: "done"` with a `clip` filename: the scene is cuttable.
392
+ - `wordsFrom: "voice"`, or a scene whose storyboard entry has `voText` and no
393
+ on-camera line: the clip is picture only and the voice track is the sound.
394
+ Do not use that clip's own audio.
395
+ - `manifest.narration` present: listen to `narration.mp3` end to end, then the
396
+ joins between adjacent `scene-NN.voice` files, before you trust the cut.
397
+ Matching voice IDs and a one-speaker diarization result do not prove
398
+ continuity (#1802).
399
+ - `flagged: true` is a warning, not a block. The clip was delivered anyway; the
400
+ findings say what the QC model saw (`wrong-character`, `eyeline-off`,
401
+ `set-drift`, `speech-cutoff`, `framing-off`). Tell the user which scenes are
402
+ flagged and why, and let them decide whether to keep, trim, or drop each one.
403
+ A `speech-cutoff` on the CTA scene is the one to worry about; the ad's last
404
+ words are missing. `status` shows the checker's own wording behind each
405
+ finding; the manifest carries only the verdict.
406
+ - `clipStatus: "failed"` or `"missing"` with a `keyframe`: `durationSec` is null
407
+ (it comes from the clip), but the scene can still hold its place as a still —
408
+ for the length of its voice track, or the storyboard's planned duration when
409
+ there is no voice.
410
+ - `clipStatus: "running"` or `"pending"`: pull again later. `status` says when
411
+ the run parks at the final watch, which means nothing is still rendering.
412
+ - `failed[]` non-empty: run the same `pull` again.
413
+
414
+ ## Cutting the ad
415
+
416
+ Editing is the part every user does differently, and the team is deliberately
417
+ still finding out what works. Nothing below is the house way. Ask the user which
418
+ they want; if they have not said and are not around, say which you used and why.
419
+
420
+ - **The bundled script** — `node .claude/skills/exodus-video/scripts/first-cut.mjs
421
+ ./ad-<runId>`, path from the workspace root (from a brand subfolder, prefix
422
+ `../`). A plain assembly with no editing decisions in it, for seeing the whole
423
+ ad end to end quickly. Read "What the bundled script does" before you run it:
424
+ it changes the audio.
425
+ - **HyperFrames** — HTML/CSS compositions rendered to video. Suits an ad that
426
+ needs captions, title cards, overlays or motion graphics laid over the clips.
427
+ It is a separate skill pack; check that a `hyperframes` skill is installed
428
+ before offering it.
429
+ - **An NLE over MCP** — DaVinci Resolve publishes an MCP server, so a timeline
430
+ can be built in a real editor and the user can carry on by hand afterwards.
431
+ Suits a user who wants to keep editing after you stop. Check what MCP servers
432
+ are actually connected first; do not assume one is there.
433
+ - **ffmpeg by hand** — full control with nothing extra to install. Suits a trim,
434
+ a reorder, an aspect change, or anything the others make awkward.
435
+
436
+ Whatever you use, the only thing the product requires of the output is that
437
+ `upload` accepts it (see below).
438
+
439
+ ### What the storyboard asks the cut to do
440
+
441
+ Read these out of `storyboard.json` before you build anything, in any tool:
442
+
443
+ - **Running order.** A-roll scenes (no `kind`, or `kind: "aroll"`) in
444
+ `sceneIndex` order are the spine. Their summed length is the ad's length.
445
+ - **Cutaways.** `kind: "cutaway"` + `cueLineId`: lay the picture over the spine
446
+ at the moment that script line is spoken, taking no audio and no place in the
447
+ running order. To find the moment, locate the A-roll scene whose `lineIds`
448
+ contain the cue line, then read the start time of the first word of that line
449
+ out of that scene's `words.json`.
450
+ - **Overlays and SFX.** `overlays[]` and `audio.sfxCues[]` are cued the same
451
+ way, by `cueLineId`. Nothing renders them for you.
452
+ - **Captions.** Nothing is burned into any clip. Captions belong in post; the
453
+ word timings are what an editor imports to build them.
454
+
455
+ ### What the bundled script does
456
+
457
+ State this to the user before you run it, because an editor does not expect a
458
+ cut tool to alter the performance:
459
+
460
+ - **It changes the narration's speed.** When a narrated scene's voice runs
461
+ longer than its clip, it speeds that scene's voice up — to at most 1.26×
462
+ (`ATEMPO_CAP`, `scripts/first-cut.mjs:39`) — and holds the last frame for the
463
+ remainder. The amount differs per scene, so one ad can play scene 1 at normal
464
+ speed and scene 3 a quarter faster. **This is the script's behaviour, not the
465
+ system's**; nothing in the pipeline does it. It is the defect tracked in
466
+ #1814. If the user cares about a single consistent narrator pace, cut
467
+ elsewhere, or skip the scenes it stretches.
468
+ - **It ignores the continuous master.** It reads only `scene-NN.voice` files;
469
+ it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
470
+ master down whole and cuts the pictures to it keeps the read intact.
471
+ - Otherwise: A-roll in manifest order as the spine, each cutaway laid over the
472
+ spine at its cued word (falling back to a proportional estimate when the scene
473
+ has no word times, and saying which method it used), a narrated scene's voice
474
+ replacing whatever the clip recorded, a dialogue scene keeping its performed
475
+ line, a clip-less scene becoming a still, everything normalized to 1080×1920
476
+ at 24 fps and −16 LUFS per segment, a generated room-tone bed under the whole
477
+ ad, and the music bed underneath at low level. Output is H.264 + AAC in an MP4
478
+ with `-movflags +faststart`.
479
+ - Flags: `--out <file>`, `--skip 2,5`, `--no-music`. It prints what went in,
480
+ what was left out and why, and the upload command.
481
+ - Beside the MP4 it writes `cut.words.json` (every spoken word of the finished
482
+ ad with its start and end) and `cut.srt` (the same words grouped into subtitle
483
+ lines), named from the `--out` basename. Those times are on the finished ad's
484
+ timeline and include the speed changes above. Scenes with no word times leave
485
+ gaps and are named in the report; when no scene delivered any, neither file is
486
+ written. These sidecars are the script's own output — another tool will not
487
+ produce them.
488
+
489
+ Before uploading anything, from any tool, check what you made:
490
+ `ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 cut.mp4`
491
+ should be close to the sum of the scene durations you kept. A cut that is a few
492
+ seconds long when five clips went in means a filter dropped inputs; do not
493
+ upload it.
494
+
495
+ ## What the product actually requires
496
+
497
+ These are the system's rules. They hold no matter which tool made the cut.
498
+
499
+ **`upload` accepts** `.mp4`, `.m4v`, `.mov` or `.webm`, up to 200 MB. The server
500
+ re-derives the type from the stored bytes and rejects anything that is not video.
501
+ The length comes from `--duration` if you pass it, else `ffprobe`, else the MP4
502
+ header; when none of those works the CLI says so and asks for `--duration`. The
503
+ length you give is recorded as stated and never measured against the file, so
504
+ give a real one.
505
+
506
+ **`upload` only works at the final watch.** The run has to be parked there with
507
+ its video step done, or the server answers "This ad isn't waiting for your final
508
+ cut right now." On a workflow run that park exists only when the video node sets
509
+ `finalWatch: true`; a Show run always has it. Uploading again replaces the
510
+ previous cut in place rather than adding a second one. Upload does not move the
511
+ run; it stays parked until someone approves.
512
+
513
+ **Approving is what makes it the ad, and it is one-way.** `approve` at the final
514
+ watch stamps the run delivered and resumes it. After that the server refuses any
515
+ further upload ("This ad is already delivered — nothing to change"). So approve
516
+ only when the user has seen the cut or told you to run unattended.
517
+
518
+ **Nothing checks that the cut matches the approved ad.** The server verifies
519
+ that the file is a video and that the run is at the right gate. It never
520
+ compares the cut against the script or the storyboard. The approved spoken copy
521
+ and the approved narration are therefore not the editor's to change — reordering
522
+ lines, re-recording narration, or cutting words out of the CTA will ship,
523
+ silently. Cut the pictures to the words, not the words to the pictures. If the
524
+ words are wrong, that is a storyboard flag or a new run, not an edit.
525
+
526
+ ## Reporting to the user
527
+
528
+ Lead with where the run is and what it needs from them. Then, when you pulled
529
+ or cut: how many scenes, which are flagged and why in one line each, what you
530
+ left out, which tool you cut with and anything it changed, the cut's length, and
531
+ the exact command or link for the next step. Never paste the manifest;
532
+ summarize it.