@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.
- package/README.md +11 -0
- package/assets/docs/CLAUDE.md +4 -2
- package/assets/docs/PIPELINES.md +26 -0
- package/assets/skills/exodus/SKILL.md +2 -0
- package/assets/skills/exodus-ads/SKILL.md +165 -0
- package/assets/skills/exodus-video/SKILL.md +532 -0
- package/assets/skills/exodus-video/scripts/first-cut.mjs +568 -0
- package/assets/skills/exodus-winners/SKILL.md +65 -17
- package/assets/skills/exodus-workflow/SKILL.md +4 -2
- package/assets/skills/find-competitors/SKILL.md +1 -1
- package/dist/bin/exodus.js +5 -0
- package/dist/commands/ads.d.ts +50 -0
- package/dist/commands/ads.js +414 -0
- package/dist/commands/comments.d.ts +28 -0
- package/dist/commands/comments.js +198 -0
- package/dist/commands/swipe.js +8 -1
- package/dist/commands/video.d.ts +273 -0
- package/dist/commands/video.js +1450 -0
- package/dist/commands/winners.d.ts +50 -0
- package/dist/commands/winners.js +175 -2
- package/dist/commands/workflow.js +34 -13
- package/dist/lib/meta-ads.d.ts +108 -0
- package/dist/lib/meta-ads.js +156 -0
- package/package.json +1 -1
|
@@ -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.
|