@aicopycoders/exodus 2026.9.1800-beta.0 → 2026.9.1900-beta.0

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.
@@ -256,10 +256,13 @@ Start (Show route):
256
256
  exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait stops at the storyboard gate
257
257
  Then, on either route:
258
258
  exodus video storyboard <runId> [--json] the scene cards
259
+ exodus video voices <runId> [--set <who>=<voiceId>] [--clear <who>] [--from <f>] [--json] who speaks, with whose voice
259
260
  exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
260
261
  exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back — SHOW RUNS ONLY
262
+ exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "<t>"] [--json] redo one still at the pixel gate
261
263
  exodus video status <runId> [--json] the park and each scene's clip/voice/picture
262
264
  exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
265
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "<t>"] [--json] redo one clip at the final watch
263
266
  exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
264
267
  Cut: your choice of tool — Exodus does not stitch or mix. One option ships with the skill:
265
268
  node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
@@ -75,6 +75,8 @@ exodus workflow list the brand's workflows
75
75
  exodus workflow describe <workflowId|name> what inputs it needs
76
76
  exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
77
77
  start, stop at the storyboard gate
78
+ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voices.json --wait
79
+ same, with the brand's voices already chosen
78
80
  exodus video status <runId> where it is, per scene
79
81
  ```
80
82
 
@@ -96,6 +98,8 @@ exodus video flag <runId> --note "what's wrong" send it back — SHOW
96
98
  exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
97
99
  exodus video status <runId> where it is, per scene
98
100
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
101
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"]
102
+ redo one clip at the final watch
99
103
  … make the cut (your choice of tool)
100
104
  exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
101
105
  exodus video approve <runId> make it the ad
@@ -115,7 +119,8 @@ per-scene detail, and the checkpoint verbs cannot resolve a video park — see
115
119
  under it:
116
120
 
117
121
  - storyboard gate: needs `approve`, or `flag` on a Show run.
118
- - final watch: every piece is made; needs `pull`, a cut, and `upload`. A
122
+ - final watch: every piece is made; needs `pull`, a cut, and `upload`. It is also
123
+ the one park where a finished clip can be redone, with `retry-clip`. A
119
124
  workflow run only reaches this park if its video node sets `finalWatch: true`
120
125
  — otherwise it skips straight to finished.
121
126
  - paused: a builder checkpoint neither video verb resolves —
@@ -170,6 +175,7 @@ Verified against the routes, not assumed. Everything here is admin-gated on top.
170
175
  | `video flag --note` | **refused** | works |
171
176
  | script-turn edits at the gate | **refused** | dashboard only |
172
177
  | `video retry-frame` | works | works |
178
+ | `video retry-clip` | works | works |
173
179
  | `video pull` | works | works |
174
180
  | `video upload` | only if the video node sets `finalWatch: true` | works |
175
181
  | `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
@@ -218,11 +224,14 @@ Five consequences worth knowing before you promise a user anything:
218
224
  slots park is still not a landing: it prints its notice once and the loop
219
225
  keeps polling, so a resolve from another shell carries the run on. Do not read
220
226
  a park banner mid-poll as the wait having finished.
221
- - **The run's page link differs.** A Show run's `dashboardUrl` opens
222
- `/video?show=…&ad=…`; a showless run gets `/runs/<runId>`, because the `/video`
223
- page cannot render one (`convex/http.ts:9204-9211`). The manifest's
224
- `dashboardUrl` is always `/video?ad=<runId>`, which is the Show-route shape —
225
- on a workflow run, hand the user the link `upload` prints, not the manifest's.
227
+ - **The run's page link differs, and every link the CLI prints follows the run.**
228
+ A Show ad opens on the `/video` page. A showless workflow run opens on
229
+ `/workflows/<workflowId>/runs/<runId>`, because `/video` answers "That ad isn't
230
+ here" for a run with no Show (`getAdDetail` rejects it;
231
+ `convex/http.ts:9204-9211`). Since #1851 the manifest's `dashboardUrl`, the
232
+ link in every park block and the link `upload` prints are all minted that way,
233
+ so they agree on both routes. When all the CLI holds is a run id it mints
234
+ `/runs/<runId>`, the canonical forwarder, which lands on the same page.
226
235
 
227
236
  ## The script file
228
237
 
@@ -248,8 +257,10 @@ THE SOFTGEL: Tap below and give your eyes ClearBlink.
248
257
  ## Reading the storyboard gate
249
258
 
250
259
  `storyboard <runId>` prints one card per scene on both routes: who speaks, the
251
- line, the planned duration, and whether its picture is drawn. Read it for the
252
- user, then ask one question.
260
+ line, the planned duration, and whether its picture is drawn. Under the cards it
261
+ also lists the voices — one line per character who speaks or already has a voice
262
+ — so the user can see who sounds like whom before paying for the clips. Read it
263
+ for the user, then ask one question.
253
264
 
254
265
  On a **workflow run** the question is approve or don't — there is no flag. If
255
266
  the storyboard is wrong, say so plainly and let the user decide between
@@ -302,6 +313,17 @@ on that account. Each refusal says the plain reason and saves nothing. If
302
313
  ElevenLabs simply cannot be reached, the choice IS saved and the output says the
303
314
  check could not be made — a provider outage never blocks the run.
304
315
 
316
+ **Approving checks the voices one last time.** `video approve` re-checks every
317
+ chosen voice before the clips are paid for. If ElevenLabs is certain one of them
318
+ is gone — deleted from the account, or the account changed — the approve is
319
+ refused in one plain sentence, nothing is approved and the command exits 1. Fix
320
+ it by choosing another voice (`video voices <runId> --set <character>=<voiceId>`)
321
+ or clearing that character (`--clear <character>`, which lets the clip keep the
322
+ voice the video model makes), then approve again. When the approve goes through,
323
+ the receipt lists each speaking character and the voice they got, plus any
324
+ `Heads-up:` lines worth reading — that is the last moment a wrong voice is cheap
325
+ to fix.
326
+
305
327
  **The voices file.** A plain JSON object, keyed by the names the script uses:
306
328
 
307
329
  ```json
@@ -319,6 +341,18 @@ flag says `C2`, that is one character named two ways, and the whole request is
319
341
  refused with a sentence saying so. Voice IDs belong to the user's own ElevenLabs account and to that brand's
320
342
  folder — never put a brand's voice IDs in a shared workflow or template.
321
343
 
344
+ **One file, two moments.** A brand keeps a single `voices.json` in its own
345
+ folder. Hand it to the run at the start with `exodus workflow run <workflow>
346
+ --voices @voices.json` when the script is one the user supplied (it names its
347
+ speakers, so `HOST 1` means something before the planner has run), or hand it in
348
+ at the storyboard review with `exodus video voices <runId> --from voices.json`.
349
+ Launching with voices refuses before the run is created — nothing is spent — if
350
+ a speaker is not in the script, the same speaker is named twice, or ElevenLabs
351
+ is certain it does not have one of the voices; a workflow whose script is
352
+ written by a bot is refused with a pointer at the review-time command, because
353
+ nobody knows the speaker names yet. When the storyboard arrives, the voices are
354
+ already set and `exodus video voices <runId>` shows them.
355
+
322
356
  **The JSON receipt** (`--json`), the same shape for reading and for setting:
323
357
 
324
358
  ```
@@ -378,18 +412,28 @@ manifest.json the index below
378
412
  `manifest.json`:
379
413
 
380
414
  ```
381
- runId, pulledAt, dashboardUrl the run and its page (/video?ad=<runId>)
415
+ runId, pulledAt, dashboardUrl the run and the page that opens it:
416
+ /video?ad=<runId> for a Show ad,
417
+ /workflows/<workflowId>/runs/<runId> for a
418
+ workflow run
382
419
  storyboard, reference, music filenames, or null when not delivered
383
420
  narration { file, timing }, or null on a per-scene run
384
421
  cast[] one per identity still
385
422
  characterId, name from the run's cast lock; null for an
386
423
  unclaimed ledger still
387
424
  file, status, error
425
+ voiceId, voiceLabel the ElevenLabs voice this character was
426
+ given before the clips were made; null when
427
+ nobody chose one
388
428
  scenes[] one per scene, in order
389
429
  sceneIndex, durationSec the run's number and the clip's real length
390
430
  clip, voice, keyframe, words filenames, or null
391
431
  wordsFrom clip | voice — which file the word times are
392
432
  measured against; null when none came
433
+ wordsDescribe this-file | original-performance — whether
434
+ those times were measured on the file you
435
+ have, or on the take a cast voice replaced;
436
+ null when no words came
393
437
  clipStatus done | failed | running | pending | missing
394
438
  error why the clip failed, when it did
395
439
  flagged, findings[] QC verdict: {check, code, severity: fail|warn, detail}
@@ -466,6 +510,18 @@ re-based to the start of that scene's cut; on a per-scene run it is instead a
466
510
  blind Scribe pass over that scene's own voice track. Nothing is aligned to the
467
511
  finished ad — that timeline does not exist until you make it.
468
512
 
513
+ **How far to trust them** (`wordsDescribe`). `"this-file"` means the times were
514
+ measured on the very file `wordsFrom` names, so they hold to the frame: cut
515
+ captions and cue points straight off them. `"original-performance"` means the
516
+ scene's clip was re-voiced *after* its words were timed — the times describe the
517
+ take the cast voice replaced. The words and their order are right and the timing
518
+ is close, but nothing re-measures it, so check any cut that has to land on a
519
+ single frame. A clip carrying a `voice-timing-shifted` warning is the same thing
520
+ with a measured gap: the swapped audio came back a different length, so treat
521
+ that scene's times as a rough guide and watch it before you trust it. Nothing in
522
+ the pipeline speeds audio or video up or slows it down to make the two agree —
523
+ the timings are reported honestly instead.
524
+
469
525
  **Cast identity stills** (`cast-*.<ext>`). A full-body identity still per cast
470
526
  member, on a plain neutral background in the run's style, minted once and
471
527
  frozen onto the run as its cast lock. Every scene picture was anchored to these,
@@ -487,14 +543,19 @@ music failure degrades to a warning rather than blocking the run.
487
543
  - `wordsFrom: "voice"`, or a scene whose storyboard entry has `voText` and no
488
544
  on-camera line: the clip is picture only and the voice track is the sound.
489
545
  Do not use that clip's own audio.
546
+ - `wordsDescribe: "original-performance"`: the word times were measured before
547
+ that clip's cast voice was swapped in. Close, not frame-exact — say so if the
548
+ user is cutting captions to the frame.
490
549
  - `manifest.narration` present: listen to `narration.mp3` end to end, then the
491
550
  joins between adjacent `scene-NN.voice` files, before you trust the cut.
492
551
  Matching voice IDs and a one-speaker diarization result do not prove
493
552
  continuity (#1802).
494
553
  - `flagged: true` is a warning, not a block. The clip was delivered anyway; the
495
554
  findings say what the QC model saw (`wrong-character`, `eyeline-off`,
496
- `set-drift`, `speech-cutoff`, `framing-off`). Tell the user which scenes are
497
- flagged and why, and let them decide whether to keep, trim, or drop each one.
555
+ `set-drift`, `speech-cutoff`, `framing-off`, `voice-timing-shifted`). Tell the
556
+ user which scenes are flagged and why, and let them decide whether to keep,
557
+ trim, drop or redo each one (`retry-clip`, see "Redoing one clip at the final
558
+ watch").
498
559
  A `speech-cutoff` on the CTA scene is the one to worry about; the ad's last
499
560
  words are missing. `status` shows the checker's own wording behind each
500
561
  finding; the manifest carries only the verdict.
@@ -506,6 +567,49 @@ music failure degrades to a warning rather than blocking the run.
506
567
  the run parks at the final watch, which means nothing is still rendering.
507
568
  - `failed[]` non-empty: run the same `pull` again.
508
569
 
570
+ ## Redoing one clip at the final watch
571
+
572
+ `retry-clip` re-renders ONE scene's clip on the run that already made it. There
573
+ is no second run, no new storyboard and no re-render of anything else.
574
+
575
+ ```
576
+ exodus video retry-clip <runId> --scene 3
577
+ exodus video retry-clip <runId> --scene 3 --note "keep both hands in frame on the handshake"
578
+ exodus video retry-clip <runId> --scene 3 --node video-2
579
+ ```
580
+
581
+ `--node` is only needed when a run has clips for that scene on two video steps;
582
+ the CLI asks for it by name when it does.
583
+
584
+ **When it is allowed.** A failed clip, once the step that made it has finished. A
585
+ finished clip, only while the run is parked at the final watch, or when QC
586
+ flagged it (`flagged: true` in `status` and in the manifest). Everything else is
587
+ refused in one plain sentence before any task is queued: a step still rendering
588
+ its other scenes or one that failed outright, a scene already being redone, a
589
+ finished clip on a run that is not at its final watch. An ad that is already
590
+ delivered is refused by the server, in its own sentence.
591
+
592
+ **What stays untouched.** Every other scene's clip, every keyframe, the cast
593
+ pins, the voices and the approved script. The redone clip re-renders from the
594
+ same keyframe with the same voice path the first take used, so it comes back
595
+ revoiced and trimmed the same way, with fresh findings and fresh word timings.
596
+
597
+ **A `--note` steers the motion, never the words.** It is folded into that
598
+ scene's motion prompt only (`withRedoDirection`,
599
+ `scout/src/trigger/workflow-scene-retry.ts`); the spoken line still comes from
600
+ the approved storyboard. Asking for different words in a note will not change
601
+ them. If the words are wrong, that is a storyboard flag or a new run.
602
+
603
+ **A redo whose take fails the checks keeps the original clip.** On a finished
604
+ clip the row stays `done` with the ORIGINAL take and only its findings are
605
+ refreshed, because a worse take must never replace a delivered one. So never
606
+ report that the clip changed. Read `status` again for the new findings, then pull
607
+ and compare the file before and after.
608
+
609
+ **Re-cut afterwards.** A cut you already uploaded still holds the old clip, and
610
+ the CLI says so when that is the case. Pull again, re-cut and upload again before
611
+ anyone approves.
612
+
509
613
  ## Cutting the ad
510
614
 
511
615
  Editing is the part every user does differently, and the team is deliberately
@@ -70,6 +70,7 @@ export interface VideoRunNode {
70
70
  kind: string;
71
71
  status: "idle" | "running" | "done" | "failed" | "skipped";
72
72
  error?: string;
73
+ warning?: string;
73
74
  outputs?: ArtifactSubset[];
74
75
  }
75
76
  export type BuilderPauseReason = "taste" | "repair" | "slots" | "call" | "checkpoint";
@@ -96,6 +97,8 @@ export interface VideoRun {
96
97
  pausedNodeId?: string;
97
98
  nodes: VideoRunNode[];
98
99
  castLock?: PullCastLock | null;
100
+ workflowId?: string;
101
+ moduleOwned?: boolean;
99
102
  }
100
103
  export declare function asVideoRun(data: unknown): VideoRun;
101
104
  export interface NodeItem {
@@ -177,7 +180,8 @@ export type RunStop = {
177
180
  export declare function classifyRun(run: VideoRun): RunStop;
178
181
  export declare function stageWord(stage: string): string;
179
182
  export declare function stepName(kind: string | undefined): string;
180
- export declare function stopLines(stop: RunStop, runId: string, dashboardUrl: string): string[];
183
+ export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId" | "moduleOwned">): string;
184
+ export declare function stopLines(stop: RunStop, runId: string, runUrl: string): string[];
181
185
  export interface PullDownload {
182
186
  file: string;
183
187
  url: string;
@@ -192,6 +196,7 @@ export interface ManifestScene {
192
196
  clip: string | null;
193
197
  words: string | null;
194
198
  wordsFrom: "clip" | "voice" | null;
199
+ wordsDescribe: "original-performance" | "this-file" | null;
195
200
  voice: string | null;
196
201
  keyframe: string | null;
197
202
  qc: ClipQc | null;
@@ -214,6 +219,8 @@ export interface ManifestCastRef {
214
219
  file: string | null;
215
220
  status: string;
216
221
  error: string | null;
222
+ voiceId: string | null;
223
+ voiceLabel: string | null;
217
224
  }
218
225
  export interface VideoManifest {
219
226
  runId: string;
@@ -265,6 +272,22 @@ export declare function storyboardFlow(runId: string, json: boolean, deps: Video
265
272
  export declare function approveFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
266
273
  export declare function flagFlow(runId: string, note: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
267
274
  export declare function retryFrameFlow(runId: string, nodeId: string, sceneIndex: number, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
275
+ export type ClipRedoPlan = {
276
+ ok: true;
277
+ nodeId: string;
278
+ sceneIndex: number;
279
+ attempt: number;
280
+ warnings: string[];
281
+ } | {
282
+ ok: false;
283
+ reason: string;
284
+ };
285
+ export interface ClipRedoTarget {
286
+ sceneIndex: number;
287
+ nodeId?: string;
288
+ }
289
+ export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
290
+ export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
268
291
  export interface CastVoiceRow {
269
292
  characterId: string;
270
293
  name: string;
@@ -40,6 +40,10 @@ The whole loop, in order:
40
40
  7. exodus video pull <runId> --out ./ad
41
41
  Writes every piece to that folder plus a manifest.json index.
42
42
 
43
+ exodus video retry-clip <runId> --scene <n>
44
+ Redo ONE finished clip while the run waits for the cut. Everything
45
+ else stays. A --note steers the motion, never the words.
46
+
43
47
  8. Make your cut from those files.
44
48
 
45
49
  9. exodus video upload <runId> --file cut.mp4
@@ -56,6 +60,7 @@ Usage:
56
60
  exodus video approve <runId> [--json]
57
61
  exodus video flag <runId> --note "<what is wrong>" [--json]
58
62
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
63
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
59
64
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
60
65
  exodus video pull <runId> --out <dir> [--json]
61
66
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
@@ -72,9 +77,12 @@ Options:
72
77
  --duration <sec> How long your cut is, in seconds. Only needed when the
73
78
  length can't be read off the file itself
74
79
  --note "<text>" What is wrong with the storyboard (flag), or how to
75
- steer one frame redo (retry-frame)
76
- --node <nodeId> Which scene-frames node holds the still (retry-frame)
77
- --scene <n> Which scene's frame to redo. One scene only (retry-frame)
80
+ steer one redo (retry-frame, retry-clip)
81
+ --node <nodeId> Which scene-frames node holds the still (retry-frame).
82
+ Which video step holds the clip, when a scene has one on
83
+ more than one step (retry-clip)
84
+ --scene <n> Which scene to redo. One scene only, a whole number
85
+ (retry-frame, retry-clip)
78
86
  --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
79
87
  character by its ID or by the name your script uses.
80
88
  Repeat it once per character
@@ -93,6 +101,7 @@ Examples:
93
101
  exodus video storyboard run_123
94
102
  exodus video approve run_123
95
103
  exodus video retry-frame run_123 --node frames-1 --scene 2
104
+ exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
96
105
  exodus video voices run_123
97
106
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
98
107
  exodus video voices run_123 --from voices.json
@@ -260,7 +269,14 @@ export function stepName(kind) {
260
269
  return "A step in this run";
261
270
  return STEP_NAMES[kind] ?? `The ${kind} step`;
262
271
  }
263
- export function stopLines(stop, runId, dashboardUrl) {
272
+ export function reviewUrl(dashboardUrl, run) {
273
+ if (run.moduleOwned === true)
274
+ return `${dashboardUrl}/video?ad=${run._id}`;
275
+ if (run.workflowId)
276
+ return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
277
+ return `${dashboardUrl}/runs/${run._id}`;
278
+ }
279
+ export function stopLines(stop, runId, runUrl) {
264
280
  if (stop.at === "storyboard-gate") {
265
281
  const lines = [
266
282
  "Parked: the storyboard is waiting for your yes.",
@@ -278,6 +294,8 @@ export function stopLines(stop, runId, dashboardUrl) {
278
294
  "Parked: every piece is made and the run is waiting for a cut.",
279
295
  `Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
280
296
  `Then upload: exodus video upload ${runId} --file cut.mp4`,
297
+ `Redo one clip: exodus video retry-clip ${runId} --scene <n>`,
298
+ `Watch it here: ${runUrl}`,
281
299
  ];
282
300
  }
283
301
  if (stop.at === "failed") {
@@ -285,7 +303,7 @@ export function stopLines(stop, runId, dashboardUrl) {
285
303
  return [
286
304
  `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
287
305
  `Try that step again: exodus workflow repair ${runId} retry`,
288
- `Or open the run: ${dashboardUrl}/video?ad=${runId}`,
306
+ `Or open the run: ${runUrl}`,
289
307
  ];
290
308
  }
291
309
  return [
@@ -302,7 +320,7 @@ export function stopLines(stop, runId, dashboardUrl) {
302
320
  if (stop.at === "paused") {
303
321
  return [
304
322
  `Parked: this run is waiting on someone${stop.reason ? ` (${stop.reason})` : ""}.`,
305
- `Open it: ${dashboardUrl}/video?ad=${runId}`,
323
+ `Open it: ${runUrl}`,
306
324
  ];
307
325
  }
308
326
  return [`Working: ${stageWord(stop.stage)}.`];
@@ -358,7 +376,31 @@ function reservedCastFrames(items) {
358
376
  }
359
377
  return byIndex;
360
378
  }
361
- function planCastRefs(run, items) {
379
+ function castVoicePins(artifact) {
380
+ const pins = new Map();
381
+ let envelope = artifact?.storyboard;
382
+ if (envelope === undefined && typeof artifact?.storyboardJson === "string") {
383
+ try {
384
+ envelope = JSON.parse(artifact.storyboardJson);
385
+ }
386
+ catch {
387
+ return pins;
388
+ }
389
+ }
390
+ const cast = envelope?.cast;
391
+ if (!Array.isArray(cast))
392
+ return pins;
393
+ for (const member of cast) {
394
+ const characterId = typeof member.characterId === "string" ? member.characterId.trim() : "";
395
+ const voiceId = typeof member.voiceId === "string" ? member.voiceId.trim() : "";
396
+ if (!characterId || !voiceId)
397
+ continue;
398
+ const label = typeof member.voiceLabel === "string" ? member.voiceLabel.trim() : "";
399
+ pins.set(characterId, { voiceId, voiceLabel: label ? label : null });
400
+ }
401
+ return pins;
402
+ }
403
+ function planCastRefs(run, items, pins) {
362
404
  const ledger = reservedCastFrames(items);
363
405
  const ledgerRows = [...ledger.entries()].sort(([a], [b]) => a - b);
364
406
  const locked = run.castLock?.cast;
@@ -422,12 +464,15 @@ function planCastRefs(run, items) {
422
464
  const downloads = [];
423
465
  for (const row of rows) {
424
466
  const file = row.url ? `${row.stem}.${extFor(row.url, "image")}` : null;
467
+ const pin = row.characterId ? pins.get(row.characterId) : undefined;
425
468
  cast.push({
426
469
  characterId: row.characterId,
427
470
  name: row.name,
428
471
  file,
429
472
  status: row.status,
430
473
  error: row.error,
474
+ voiceId: pin?.voiceId ?? null,
475
+ voiceLabel: pin?.voiceLabel ?? null,
431
476
  });
432
477
  if (file && row.url)
433
478
  downloads.push({ file, url: row.url });
@@ -598,6 +643,11 @@ export function planPull(run, items, opts) {
598
643
  clip: clip?.download.file ?? null,
599
644
  words,
600
645
  wordsFrom,
646
+ wordsDescribe: wordsFrom === null
647
+ ? null
648
+ : clip?.revoiced === true && wordsFrom === "clip"
649
+ ? "original-performance"
650
+ : "this-file",
601
651
  voice: voice?.download.file ?? null,
602
652
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
603
653
  qc: clip?.qc ?? null,
@@ -610,7 +660,7 @@ export function planPull(run, items, opts) {
610
660
  keyframeFindings: frameItem?.findings?.map(withoutJudgeFields) ?? null,
611
661
  };
612
662
  });
613
- const { cast, downloads: castDownloads } = planCastRefs(run, items);
663
+ const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
614
664
  const downloads = [
615
665
  ...(reference ? [reference] : []),
616
666
  ...keyframeByScene.values(),
@@ -626,7 +676,7 @@ export function planPull(run, items, opts) {
626
676
  manifest: {
627
677
  runId: run._id,
628
678
  pulledAt: opts.pulledAt,
629
- dashboardUrl: `${opts.dashboardUrl}/video?ad=${run._id}`,
679
+ dashboardUrl: reviewUrl(opts.dashboardUrl, run),
630
680
  storyboard,
631
681
  reference: reference?.file ?? null,
632
682
  music: music?.file ?? null,
@@ -668,6 +718,7 @@ export function markPullFailure(manifest, failure) {
668
718
  if (scene.words === failure.file || lostWordsSource) {
669
719
  scene.words = null;
670
720
  scene.wordsFrom = null;
721
+ scene.wordsDescribe = null;
671
722
  }
672
723
  }
673
724
  }
@@ -773,7 +824,7 @@ export async function startFlow(opts, deps) {
773
824
  return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
774
825
  }
775
826
  const runId = started.runId;
776
- const url = started.url ?? `${deps.dashboardUrl}/video?ad=${runId}`;
827
+ const url = started.url ?? reviewUrl(deps.dashboardUrl, { _id: runId });
777
828
  if (!opts.wait) {
778
829
  const lines = [
779
830
  `Started ad run ${runId}`,
@@ -826,7 +877,7 @@ export async function waitFlow(runId, opts, deps) {
826
877
  }
827
878
  return {
828
879
  code: stop.at === "failed" ? 1 : 0,
829
- lines: [...lines, ...stopLines(stop, runId, deps.dashboardUrl)],
880
+ lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
830
881
  };
831
882
  }
832
883
  const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
@@ -860,13 +911,22 @@ export async function statusFlow(runId, json, deps) {
860
911
  const items = itemsRes.data.items ?? [];
861
912
  const stop = classifyRun(run);
862
913
  const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
914
+ const warnings = run.nodes
915
+ .filter((n) => n.warning)
916
+ .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
863
917
  if (json) {
864
- return { code: 0, lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal })] };
918
+ return {
919
+ code: 0,
920
+ lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
921
+ };
865
922
  }
866
923
  const revoicedByScene = new Set();
924
+ const timedOnOriginalVoice = new Set();
867
925
  for (const artifact of outputsOfNodeKind(run, "video")) {
868
926
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
869
927
  revoicedByScene.add(artifact.sceneIndex);
928
+ if ((artifact.words?.length ?? 0) > 0)
929
+ timedOnOriginalVoice.add(artifact.sceneIndex);
870
930
  }
871
931
  }
872
932
  const byScene = new Map();
@@ -886,7 +946,11 @@ export async function statusFlow(runId, json, deps) {
886
946
  const headline = stop.at === "failed" && stop.repair
887
947
  ? `Ad run ${runId} — Stopped, a step failed`
888
948
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
889
- const lines = [headline, ...stopLines(stop, runId, deps.dashboardUrl)];
949
+ const lines = [
950
+ headline,
951
+ ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
952
+ ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
953
+ ];
890
954
  if (byScene.size === 0) {
891
955
  lines.push("", "No scenes yet — this run hasn't made anything to look at.");
892
956
  }
@@ -909,6 +973,9 @@ export async function statusFlow(runId, json, deps) {
909
973
  lines.push(` ${row.clip.error}`);
910
974
  if (revoicedByScene.has(sceneIndex))
911
975
  lines.push(" voice: cast voice applied");
976
+ if (timedOnOriginalVoice.has(sceneIndex)) {
977
+ lines.push(" word timings: measured on the original voice (close, not frame-exact)");
978
+ }
912
979
  for (const finding of row.frame?.findings ?? []) {
913
980
  lines.push(` picture: ${finding.code} (${finding.severity}): ${finding.detail}`);
914
981
  }
@@ -958,6 +1025,23 @@ export async function storyboardFlow(runId, json, deps) {
958
1025
  for (const scene of loose)
959
1026
  lines.push(...sceneCardLines(scene));
960
1027
  }
1028
+ const voiceRes = await deps
1029
+ .get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`)
1030
+ .catch(() => null);
1031
+ if (voiceRes?.ok) {
1032
+ const sheet = (voiceRes.data ?? {});
1033
+ const cast = Array.isArray(sheet.cast) ? sheet.cast : [];
1034
+ const canChange = sheet.canChange !== false;
1035
+ if (canChange || cast.some((row) => row.voice !== null)) {
1036
+ const summary = voiceSummaryLines(cast, Array.isArray(sheet.notices) ? sheet.notices : []);
1037
+ if (summary.length > 0) {
1038
+ lines.push("", ...summary);
1039
+ if (canChange) {
1040
+ lines.push(`set voices with: exodus video voices ${runId} --set <character>=<voiceId>`);
1041
+ }
1042
+ }
1043
+ }
1044
+ }
961
1045
  lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
962
1046
  if (cards.framesNodeId) {
963
1047
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
@@ -992,10 +1076,16 @@ export async function approveFlow(runId, json, deps) {
992
1076
  return errorResult(res, json);
993
1077
  if (json)
994
1078
  return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
1079
+ const voices = res.data
1080
+ ?.voices;
1081
+ const summary = voices
1082
+ ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1083
+ : [];
995
1084
  return {
996
1085
  code: 0,
997
1086
  lines: [
998
1087
  "Approved.",
1088
+ ...(summary.length > 0 ? ["", ...summary, ""] : []),
999
1089
  `See what happens next: exodus video status ${runId}`,
1000
1090
  ],
1001
1091
  };
@@ -1052,6 +1142,127 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1052
1142
  ],
1053
1143
  };
1054
1144
  }
1145
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
1146
+ "re-cut, and upload again.";
1147
+ export function planClipRedo(run, items, target) {
1148
+ const stop = classifyRun(run);
1149
+ const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
1150
+ const rows = items.filter((i) => i.itemKind === "clip" &&
1151
+ i.sceneIndex === target.sceneIndex &&
1152
+ (target.nodeId === undefined || i.nodeId === target.nodeId));
1153
+ if (rows.length === 0) {
1154
+ return { ok: false, reason: `Scene ${target.sceneIndex} has no clip to redo.` };
1155
+ }
1156
+ const nodeIds = [...new Set(rows.map((r) => r.nodeId))];
1157
+ if (nodeIds.length > 1) {
1158
+ return {
1159
+ ok: false,
1160
+ reason: `Scene ${target.sceneIndex} has a clip on more than one step (${nodeIds.join(", ")}), ` +
1161
+ "so say which one: --node <nodeId>.",
1162
+ };
1163
+ }
1164
+ const row = rows[0];
1165
+ const node = run.nodes.find((n) => n.nodeId === row.nodeId);
1166
+ if (node?.status === "running") {
1167
+ return {
1168
+ ok: false,
1169
+ reason: `The "${row.nodeId}" step is still making other scenes. Try again once it has finished ` +
1170
+ `(exodus video status ${run._id}).`,
1171
+ };
1172
+ }
1173
+ if (node && node.status !== "done") {
1174
+ return {
1175
+ ok: false,
1176
+ reason: `The "${row.nodeId}" step did not finish (it is "${node.status}"), ` +
1177
+ "and one clip can only be redone on a step that finished.",
1178
+ };
1179
+ }
1180
+ if (row.status === "running") {
1181
+ return { ok: false, reason: `Scene ${target.sceneIndex} is already being redone.` };
1182
+ }
1183
+ const redoable = row.status === "failed" ||
1184
+ (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
1185
+ if (!redoable) {
1186
+ if (row.status === "done") {
1187
+ return {
1188
+ ok: false,
1189
+ reason: `Scene ${target.sceneIndex}'s clip is finished and nothing flagged it. A finished clip ` +
1190
+ "can only be redone while the run is waiting for the final cut.",
1191
+ };
1192
+ }
1193
+ const word = ITEM_STATUS_WORD[row.status] ?? row.status;
1194
+ return {
1195
+ ok: false,
1196
+ reason: `Scene ${target.sceneIndex}'s clip is ${word}, so there is nothing to redo yet.`,
1197
+ };
1198
+ }
1199
+ return {
1200
+ ok: true,
1201
+ nodeId: row.nodeId,
1202
+ sceneIndex: target.sceneIndex,
1203
+ attempt: row.attempt ?? 0,
1204
+ warnings: uploadedCut ? [UPLOADED_CUT_WARNING] : [],
1205
+ };
1206
+ }
1207
+ export async function retryClipFlow(runId, target, note, json, deps) {
1208
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1209
+ if (!runRes.ok)
1210
+ return errorResult(runRes, json);
1211
+ const run = asVideoRun(runRes.data);
1212
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1213
+ if (!itemsRes.ok)
1214
+ return errorResult(itemsRes, json);
1215
+ const items = itemsRes.data.items ?? [];
1216
+ const plan = planClipRedo(run, items, target);
1217
+ if (!plan.ok) {
1218
+ return {
1219
+ code: 1,
1220
+ lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1221
+ };
1222
+ }
1223
+ const res = await deps.post(SCENE_RETRY_PATH, {
1224
+ runId,
1225
+ nodeId: plan.nodeId,
1226
+ sceneIndex: plan.sceneIndex,
1227
+ ...(note ? { note } : {}),
1228
+ });
1229
+ if (!res.ok)
1230
+ return errorResult(res, json);
1231
+ const triggerRunId = res.data.triggerRunId;
1232
+ const review = reviewUrl(deps.dashboardUrl, run);
1233
+ if (json) {
1234
+ return {
1235
+ code: 0,
1236
+ lines: [
1237
+ JSON.stringify({
1238
+ ok: true,
1239
+ runId,
1240
+ nodeId: plan.nodeId,
1241
+ sceneIndex: plan.sceneIndex,
1242
+ triggerRunId,
1243
+ attemptBefore: plan.attempt,
1244
+ reviewUrl: review,
1245
+ warnings: plan.warnings,
1246
+ ...(note ? { note } : {}),
1247
+ }),
1248
+ ],
1249
+ };
1250
+ }
1251
+ return {
1252
+ code: 0,
1253
+ lines: [
1254
+ `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1255
+ `triggerRunId: ${triggerRunId ?? "-"}`,
1256
+ "Every other scene stays as it is, and so do the pictures, the voices and the script.",
1257
+ ...(note
1258
+ ? ["Your note steers how this clip moves. It never changes the words that are spoken."]
1259
+ : []),
1260
+ `Watch it land: exodus video status ${runId}`,
1261
+ `Review the run: ${review}`,
1262
+ ...plan.warnings,
1263
+ ],
1264
+ };
1265
+ }
1055
1266
  export function parseVoiceFlags(argv, readFile) {
1056
1267
  const fromFile = {};
1057
1268
  const typed = {};
@@ -1138,14 +1349,30 @@ function availabilityWords(row) {
1138
1349
  return "not checked";
1139
1350
  }
1140
1351
  }
1352
+ function voiceWords(voice) {
1353
+ if (!voice)
1354
+ return "—";
1355
+ return voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
1356
+ }
1357
+ function voiceSummaryLines(cast, notices) {
1358
+ const rows = cast.filter((row) => row.spokenScenes > 0 || row.voice !== null);
1359
+ const width = Math.max(0, ...rows.map((row) => row.name.length));
1360
+ const lines = rows.length > 0 ? ["Voices"] : [];
1361
+ for (const row of rows) {
1362
+ lines.push(row.voice
1363
+ ? ` ${row.name.padEnd(width)} → ${voiceWords(row.voice)} ${availabilityWords(row)}`
1364
+ : ` ${row.name.padEnd(width)} → no voice chosen (keeps the generated voice)`);
1365
+ }
1366
+ for (const notice of notices)
1367
+ lines.push(`Heads-up: ${notice}`);
1368
+ return lines;
1369
+ }
1141
1370
  export function voiceSheetLines(sheet) {
1142
1371
  const lines = [`Voices for run ${sheet.runId}`];
1143
1372
  if (sheet.cast.length === 0)
1144
1373
  lines.push(" Nobody is in this ad yet.");
1145
1374
  for (const row of sheet.cast) {
1146
- const voice = row.voice
1147
- ? `${row.voice.label ? `${row.voice.label} (${row.voice.voiceId})` : row.voice.voiceId}`
1148
- : "—";
1375
+ const voice = voiceWords(row.voice);
1149
1376
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
1150
1377
  `speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
1151
1378
  }
@@ -1449,7 +1676,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
1449
1676
  if (!attached.ok)
1450
1677
  return errorResult(attached, json);
1451
1678
  const final = attached.data;
1452
- const finalWatchUrl = final.finalWatchUrl ?? `${deps.dashboardUrl}/video?ad=${runId}`;
1679
+ const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
1453
1680
  if (json) {
1454
1681
  return {
1455
1682
  code: 0,
@@ -1543,6 +1770,7 @@ export async function run(flags) {
1543
1770
  "approve",
1544
1771
  "flag",
1545
1772
  "retry-frame",
1773
+ "retry-clip",
1546
1774
  "voices",
1547
1775
  "pull",
1548
1776
  "upload",
@@ -1576,6 +1804,17 @@ export async function run(flags) {
1576
1804
  }
1577
1805
  return printResult(await retryFrameFlow(runId, nodeId, sceneIndex, flagString(flags, "note"), json, defaultDeps));
1578
1806
  }
1807
+ if (sub === "retry-clip") {
1808
+ const sceneRaw = flagString(flags, "scene");
1809
+ if (sceneRaw === undefined) {
1810
+ usage("video retry-clip needs --scene <n>, the scene whose clip to redo.");
1811
+ }
1812
+ const sceneIndex = Number(sceneRaw);
1813
+ if (!Number.isInteger(sceneIndex)) {
1814
+ usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
1815
+ }
1816
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
1817
+ }
1579
1818
  if (sub === "voices") {
1580
1819
  let voices;
1581
1820
  try {
@@ -3,6 +3,7 @@ import { type PollOptions, type PollResult } from "../lib/poll.js";
3
3
  import { LEGACY_WORKFLOW_RUN_STATUS_VALUES, type RunStatus } from "../lib/runStatus.js";
4
4
  import { type RunDeliverySummary } from "../lib/runVerdict.js";
5
5
  import { type Channel } from "../lib/channel.js";
6
+ import { type VoiceMap } from "./video.js";
6
7
  export declare const helpText: string;
7
8
  export type WorkflowNodeKind = "brief" | "brief-form" | "asset" | "bot" | "primer" | "image" | "image-rig" | "rig" | "storyboard" | "reference" | "scene-frames" | "video" | "voiceover" | "output" | "call" | "show-set" | "show-cast" | "show-voices" | "product-truth" | "transform" | "formatter" | "splitter" | "collector" | "checkpoint" | "prompt";
8
9
  export type WorkflowSlotState = "locked" | "auto" | "ask" | "inferred";
@@ -407,6 +408,7 @@ interface RunFlowOptions {
407
408
  fill?: string;
408
409
  autoApprove?: boolean;
409
410
  imageRigOverrides?: Record<string, unknown>;
411
+ voices?: VoiceMap;
410
412
  wait: boolean;
411
413
  json: boolean;
412
414
  out?: string;
@@ -427,6 +429,7 @@ export declare function serverWarningsToPrint(serverWarnings: unknown, alreadyPr
427
429
  export declare function rejectTerminalFlag(args: string[]): void;
428
430
  export declare function parseAutoApproveFlag(args: string[]): boolean;
429
431
  export declare function parseRigOverridesFlag(args: string[], readFile?: (path: string) => string): Record<string, unknown> | undefined;
432
+ export declare function parseVoicesFlag(args: string[], readFile?: (path: string) => string): VoiceMap | undefined;
430
433
  export declare function parseFillFlag(args: string[]): string | undefined;
431
434
  export declare function formatWorkflowList(workflows: WorkflowListItem[]): string;
432
435
  export declare function formatRecentRuns(runs: WorkflowRunProjection[]): string;
@@ -9,7 +9,7 @@ import { runVerdict } from "../lib/runVerdict.js";
9
9
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
10
10
  import { missingRouteLine } from "../lib/route-support.js";
11
11
  import { getChannel } from "../lib/channel.js";
12
- import { asVideoRun, classifyRun, stopLines } from "./video.js";
12
+ import { asVideoRun, classifyRun, reviewUrl, stopLines, } from "./video.js";
13
13
  export const helpText = `
14
14
  exodus workflow — List, describe, run, inspect, import, and export saved workflows
15
15
 
@@ -20,7 +20,7 @@ Usage:
20
20
  exodus workflow templates [list] [--json]
21
21
  exodus workflow templates export <key> [--out <file>] [--json]
22
22
  exodus workflow schema [--kind <kind>] [--face <face>] [--json]
23
- exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--rig-overrides <json|@file>] [--auto-approve] [--wait] [--out <dir>] [--json]
23
+ exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--auto-approve] [--wait] [--out <dir>] [--json]
24
24
  exodus workflow status [--json]
25
25
  exodus workflow status --id <runId> [--out <dir>] [--json]
26
26
  exodus workflow cancel <runId> [--reason "..."] [--json]
@@ -83,6 +83,25 @@ Flags:
83
83
  before anything runs, and the error names it. On
84
84
  "triggers fire" it REPLACES the schedule's own
85
85
  overrides for that one test fire.
86
+ --voices <json> (run) Give the ad's characters their voices at the
87
+ moment the run starts, instead of at the storyboard
88
+ review. Keyed by the names the SCRIPT uses for its
89
+ speakers ("HOST 1") — the planner's character ids
90
+ (C1, C2) do not exist yet when a run starts. Takes a
91
+ JSON object, or @path to a .json file holding one:
92
+ --voices @voices.json
93
+ --voices '{"HOST 1":"<voiceId>"}'
94
+ Same file "exodus video voices <run> --from" reads at
95
+ the review, so a brand keeps ONE voices.json for both.
96
+ A speaker the script does not have, the same speaker
97
+ named twice, or a voice ElevenLabs does not have stops
98
+ the launch before the run is created — nothing is
99
+ spent, and the message says which one. A workflow whose
100
+ script is written by a bot (so nobody knows the speaker
101
+ names at the start) is told to use "exodus video voices
102
+ <run>" at the storyboard review instead. Voice ids
103
+ belong to the brand's own folder — never put them in a
104
+ shared workflow or template.
86
105
  --auto-approve (run) Deliberately unattended launch: every Checkpoint
87
106
  box this run stops at is approved automatically, with
88
107
  whatever it was holding left exactly as it is, and the
@@ -174,6 +193,7 @@ Examples:
174
193
  exodus workflow run "Launch Flow" --auto-approve --wait
175
194
  exodus workflow run "Product Shots" --rig-overrides '{"rig_1":{"lines":{"line_1":{"count":3}}}}'
176
195
  exodus workflow run "Product Shots" --rig-overrides @rig.json --wait
196
+ exodus workflow run "Podcast Ad" --input script=@script.txt --voices @voices.json --wait
177
197
  exodus workflow run "Launch Flow" --wait --out ./deliverables
178
198
  exodus workflow status # recent runs + their ids
179
199
  exodus workflow status --id wr_123
@@ -235,6 +255,19 @@ Notes:
235
255
  message says which key was wrong. A trigger can carry the same payload in its
236
256
  YAML ("imageRigOverrides:" beside its schedule), so a Monday schedule and a
237
257
  Friday one can fire the same workflow at different sizes.
258
+ "workflow run --voices" hands a video run its voices at the start, so a brand
259
+ that always uses the same hosts stops choosing them by hand on every run. The
260
+ file is keyed by the names the script uses for its speakers, because that is
261
+ all that exists before the planner has run — it is the SAME voices.json
262
+ "exodus video voices <run> --from" reads at the storyboard review, so one file
263
+ per brand serves both moments. A speaker the script does not have, the same
264
+ speaker named twice, or a voice ElevenLabs does not have stops the launch
265
+ before the run exists and nothing is spent. When the storyboard is planned,
266
+ each named speaker's character already has their voice, and "exodus video
267
+ voices <run>" shows them set. A workflow whose script is written by a bot has
268
+ no speaker names to key on at the start, so it is refused with a pointer at
269
+ the review-time command. Keep voice ids in the brand's own folder, never in a
270
+ shared workflow or template. (#1846)
238
271
  workflow triggers are addressed by 1-based position — a trigger carries no id,
239
272
  so the CLI reads the live list from the export contract and sends a fingerprint
240
273
  of the trigger's fields as the guard; a concurrent edit fails loud rather than
@@ -320,6 +353,7 @@ const VALUE_FLAGS = new Set([
320
353
  "slot",
321
354
  "reject",
322
355
  "rig-overrides",
356
+ "voices",
323
357
  "note",
324
358
  ]);
325
359
  function defaultMkdirp(dir) {
@@ -917,6 +951,63 @@ export function parseRigOverridesFlag(args, readFile) {
917
951
  }
918
952
  return parsed;
919
953
  }
954
+ export function parseVoicesFlag(args, readFile) {
955
+ let raw;
956
+ for (let i = 0; i < args.length; i++) {
957
+ const arg = args[i];
958
+ let value;
959
+ if (arg === "--voices") {
960
+ value = args[i + 1];
961
+ i++;
962
+ }
963
+ else if (arg.startsWith("--voices=")) {
964
+ value = arg.slice("--voices=".length);
965
+ }
966
+ else {
967
+ continue;
968
+ }
969
+ if (value === undefined || value.startsWith("--")) {
970
+ throw new Error("--voices requires JSON or @path/to/voices.json");
971
+ }
972
+ raw = value;
973
+ }
974
+ if (raw === undefined)
975
+ return undefined;
976
+ let text = raw.trim();
977
+ if (!text)
978
+ throw new Error("--voices requires JSON or @path/to/voices.json");
979
+ if (text.startsWith("@@")) {
980
+ text = text.slice(1);
981
+ }
982
+ else if (text.startsWith("@")) {
983
+ const filePath = text.slice(1);
984
+ if (!filePath) {
985
+ throw new Error("--voices @<file> needs a file path after \"@\"");
986
+ }
987
+ if (!readFile) {
988
+ throw new Error(`--voices: cannot load @${filePath} here (no file access)`);
989
+ }
990
+ try {
991
+ text = readFile(filePath);
992
+ }
993
+ catch (e) {
994
+ const msg = e instanceof Error ? e.message : String(e);
995
+ throw new Error(`--voices: could not read file "${filePath}": ${msg}`);
996
+ }
997
+ }
998
+ let parsed;
999
+ try {
1000
+ parsed = JSON.parse(text);
1001
+ }
1002
+ catch (e) {
1003
+ const msg = e instanceof Error ? e.message : String(e);
1004
+ throw new Error(`--voices is not valid JSON: ${msg}`);
1005
+ }
1006
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1007
+ throw new Error("--voices must be a JSON object keyed by the script's speaker names, e.g. {\"HOST 1\":\"<voiceId>\"}");
1008
+ }
1009
+ return parsed;
1010
+ }
920
1011
  export function parseFillFlag(args) {
921
1012
  let name;
922
1013
  for (let i = 0; i < args.length; i++) {
@@ -1723,6 +1814,20 @@ export async function botsFlow(opts, deps) {
1723
1814
  return { code: 0, lines: [JSON.stringify(catalog)] };
1724
1815
  return { code: 0, lines: [formatBotsList(catalog, opts.category)] };
1725
1816
  }
1817
+ function launchVoicesLine(attached) {
1818
+ if (!Array.isArray(attached))
1819
+ return undefined;
1820
+ const said = [];
1821
+ for (const raw of attached) {
1822
+ if (!raw || typeof raw !== "object")
1823
+ continue;
1824
+ const { speaker, voiceId, label } = raw;
1825
+ if (typeof speaker !== "string" || typeof voiceId !== "string")
1826
+ continue;
1827
+ said.push(`${speaker} → ${typeof label === "string" && label !== "" ? label : voiceId}`);
1828
+ }
1829
+ return said.length > 0 ? `Voices: ${said.join(", ")}` : undefined;
1830
+ }
1726
1831
  export async function runFlow(workflowRef, opts, deps) {
1727
1832
  let workflowId;
1728
1833
  try {
@@ -1765,6 +1870,7 @@ export async function runFlow(workflowRef, opts, deps) {
1765
1870
  ...(opts.fill && opts.fill.trim() !== "" ? { fill: opts.fill.trim() } : {}),
1766
1871
  ...(opts.autoApprove === true ? { autoApprove: true } : {}),
1767
1872
  ...(opts.imageRigOverrides ? { imageRigOverrides: opts.imageRigOverrides } : {}),
1873
+ ...(opts.voices ? { voices: opts.voices } : {}),
1768
1874
  launchedVia: "cli",
1769
1875
  };
1770
1876
  const start = await deps.post(RUN_PATH, body);
@@ -1778,6 +1884,7 @@ export async function runFlow(workflowRef, opts, deps) {
1778
1884
  const base = { ...data, workflowId };
1779
1885
  if (opts.json && !opts.wait)
1780
1886
  return { code: 0, lines: [JSON.stringify(base)] };
1887
+ const voicesLine = launchVoicesLine(data?.launchVoices);
1781
1888
  const lines = opts.json
1782
1889
  ? []
1783
1890
  : [
@@ -1785,6 +1892,7 @@ export async function runFlow(workflowRef, opts, deps) {
1785
1892
  "Workflow run started.",
1786
1893
  `runId: ${data.runId}`,
1787
1894
  `triggerRunId: ${data.triggerRunId}`,
1895
+ ...(voicesLine ? [voicesLine] : []),
1788
1896
  `Poll: exodus workflow status --id ${data.runId}`,
1789
1897
  ];
1790
1898
  if (!opts.wait) {
@@ -1903,7 +2011,8 @@ async function waitForRun(runId, opts, deps) {
1903
2011
  lines.push("", landOnPark.headline, ...formatPauseNotice(landOnPark.pauseReason, runId, dashboardUrl).slice(1));
1904
2012
  }
1905
2013
  if (videoStop) {
1906
- lines.push("", ...stopLines(videoStop, runId, deps.dashboardUrl ?? getDashboardUrl()));
2014
+ const review = reviewUrl(deps.dashboardUrl ?? getDashboardUrl(), asVideoRun(pollResult.data));
2015
+ lines.push("", ...stopLines(videoStop, runId, review));
1907
2016
  }
1908
2017
  return { code: pollResult.ok ? 0 : 1, lines };
1909
2018
  }
@@ -2900,19 +3009,21 @@ export async function run(flags) {
2900
3009
  const workflowRef = rest[0];
2901
3010
  if (!workflowRef) {
2902
3011
  console.error("Error: workflow run requires <workflowId|name>.");
2903
- console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--auto-approve] [--wait] [--json]");
3012
+ console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--auto-approve] [--wait] [--json]");
2904
3013
  process.exit(1);
2905
3014
  }
2906
3015
  let inputs;
2907
3016
  let fill;
2908
3017
  let autoApprove;
2909
3018
  let imageRigOverrides;
3019
+ let voices;
2910
3020
  try {
2911
3021
  inputs = parseRawInputFlags(process.argv.slice(3));
2912
3022
  rejectTerminalFlag(process.argv.slice(3));
2913
3023
  fill = parseFillFlag(process.argv.slice(3));
2914
3024
  autoApprove = parseAutoApproveFlag(process.argv.slice(3));
2915
3025
  imageRigOverrides = parseRigOverridesFlag(process.argv.slice(3), defaultDeps.readFile);
3026
+ voices = parseVoicesFlag(process.argv.slice(3), defaultDeps.readFile);
2916
3027
  }
2917
3028
  catch (e) {
2918
3029
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -2923,6 +3034,7 @@ export async function run(flags) {
2923
3034
  fill,
2924
3035
  autoApprove,
2925
3036
  imageRigOverrides,
3037
+ voices,
2926
3038
  wait: flags["wait"] === true,
2927
3039
  json,
2928
3040
  out: flagString(flags, "out"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1800-beta.0",
3
+ "version": "2026.9.1900-beta.0",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",