@aicopycoders/exodus 2026.9.2000-beta.2 → 2026.9.2000-beta.20

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.
@@ -6,11 +6,30 @@ import { displayRunStatus, formatApiError } from "../lib/format.js";
6
6
  import { missingRouteLine } from "../lib/route-support.js";
7
7
  import { hasBinary } from "../lib/preflight.js";
8
8
  import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
9
+ import { HELPER_LEDGER_BASE, SET_OPTION_LEDGER_BASE } from "../lib/helperLedgerBase.js";
10
+ const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
11
+ const VOICE_MODES = ["native", "voice-first"];
12
+ const STYLE_SLUGS = [
13
+ "pixar-3d",
14
+ "claymation-stop-motion",
15
+ "lego-toy-brick",
16
+ "balloon",
17
+ "video-game-8bit",
18
+ ];
19
+ function joinedOr(items) {
20
+ if (items.length <= 1)
21
+ return items[0] ?? "";
22
+ return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
23
+ }
24
+ function cantSayNames(names) {
25
+ return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
26
+ }
9
27
  export const helpText = `
10
28
  exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
11
29
 
12
30
  The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
13
- one voice track per scene, one video clip per scene, plus the music bed. It does
31
+ one voice track per scene, one video clip per scene, and a music bed if you
32
+ asked for one with --music. It does
14
33
  not make the finished ad. You pull the pieces to a folder, cut them together
15
34
  with whatever editing tools you like, and upload the cut back.
16
35
 
@@ -41,14 +60,22 @@ The whole loop, in order:
41
60
  Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
42
61
 
43
62
  7. exodus video status <runId>
44
- Where the run is and how each scene's clip turned out.
63
+ Where the run is and how each scene's clip turned out. If the storyboard
64
+ failed, it also says which parts of it were accepted and why the next one
65
+ was turned down.
66
+
67
+ exodus video status <runId> --rejected-draft
68
+ Prints the storyboard draft that was turned down. A record of what
69
+ happened, thrown away by the system — never something to act on.
45
70
 
46
71
  8. exodus video pull <runId> --out ./ad
47
72
  Writes every piece to that folder plus a manifest.json index.
48
73
 
49
74
  exodus video retry-clip <runId> --scene <n>
50
75
  Redo ONE finished clip while the run waits for the cut. Everything
51
- else stays. A --note steers the motion, never the words.
76
+ else stays. A --note steers the motion, never the words. Add
77
+ --voice-first when the words came out wrong: the line is recorded in
78
+ the character's own voice first, then the clip is made to match it.
52
79
 
53
80
  exodus video revoice <runId> --all
54
81
  Redo only the VOICE on clips that kept the video model's own voice
@@ -62,18 +89,21 @@ The whole loop, in order:
62
89
  Attaches your cut to the run and prints the page to approve it on.
63
90
 
64
91
  11. exodus video approve <runId>
65
- Or click Approve on the page from step 10.
92
+ Or click Approve on the page from step 10. If you redid a clip after
93
+ uploading, this stops and says which scenes changed. Upload a fresh cut,
94
+ or add --approve-stale-cut to deliver the cut you already uploaded.
66
95
 
67
96
  Usage:
68
- exodus video status <runId> [--json]
97
+ exodus video status <runId> [--rejected-draft] [--json]
69
98
  exodus video storyboard <runId> [--json]
70
- exodus video approve <runId> [--json]
99
+ exodus video approve <runId> [--approve-stale-cut] [--json]
71
100
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
72
- exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
101
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
73
102
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
74
103
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
75
104
  exodus video pull <runId> --out <dir> [--json]
76
105
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
106
+ exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--wait] [--json]
77
107
 
78
108
  Options:
79
109
  --out <dir> Folder to write the pulled pieces into (pull)
@@ -88,12 +118,33 @@ Options:
88
118
  (retry-frame, retry-clip, revoice)
89
119
  --all Every finished clip that kept the video model's voice
90
120
  (revoice)
121
+ --voice-first Record the scene's line in the character's own voice,
122
+ then make the clip to match it (retry-clip). For a clip
123
+ whose words came out wrong
124
+ --approve-stale-cut Deliver the cut you uploaded even though a clip changed
125
+ after you uploaded it (approve). Without it, approving
126
+ stops and names the scenes that changed
91
127
  --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
92
128
  character by its ID or by the name your script uses.
93
129
  Repeat it once per character
94
130
  --clear <who> Take a character's voice off again (voices). Repeatable
95
131
  --from <file.json> A file of characters and voice IDs (voices). --set and
96
132
  --clear win over the same name in the file
133
+ --rejected-draft Print the storyboard draft the system turned down
134
+ (status). It was thrown away — a record only
135
+ --script <file> Text file of what the ad says (start)
136
+ --conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
137
+ --style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
138
+ --direction "<note>" Note for the whole ad (start)
139
+ --voice-path <path> How the voices are made (start)
140
+ --video-model <id> Which video model films this ad, instead of the kind of
141
+ ad's own default (start, with --conceit)
142
+ --voice <mode> native: the video model speaks the lines. voice-first:
143
+ the lines are recorded first (start, with --conceit)
144
+ --music Put a music bed under the ad (start). An ad has no music
145
+ unless you ask for it here
146
+ --no-music Leave the music bed off (start), which it already is
147
+ --wait Wait until the storyboard needs a yes (start)
97
148
  --json Machine-readable output
98
149
  --help, -h Print this help
99
150
 
@@ -108,6 +159,7 @@ Examples:
108
159
  exodus video approve run_123
109
160
  exodus video retry-frame run_123 --node frames-1 --scene 2
110
161
  exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
162
+ exodus video retry-clip run_123 --scene 3 --voice-first
111
163
  exodus video revoice run_123 --all
112
164
  exodus video voices run_123
113
165
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
@@ -117,13 +169,20 @@ Examples:
117
169
  `.trim();
118
170
  const SHOWS_PATH = "/api/v2/shows";
119
171
  const RUNS_PATH = "/api/v2/video/runs";
172
+ const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
120
173
  const RUN_PATH = "/api/v2/workflow";
121
174
  const ITEMS_PATH = "/api/v2/workflow/items";
175
+ const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
176
+ const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
177
+ "with the next server update.";
122
178
  const STORYBOARD_PATH = "/api/v2/video/storyboard";
123
179
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
124
180
  const APPROVE_PATH = "/api/v2/workflow/approve";
125
181
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
126
182
  const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
183
+ const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
184
+ const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
185
+ "was spent. It arrives with the next server update.";
127
186
  const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
128
187
  "was spent. It arrives with the next server update.";
129
188
  const FINAL_PATH = "/api/v2/video/final";
@@ -133,6 +192,51 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
133
192
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
134
193
  return rest;
135
194
  }
195
+ export function qcWithoutJudgeWording(qc) {
196
+ if (!qc.takes)
197
+ return qc;
198
+ return {
199
+ ...qc,
200
+ takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
201
+ };
202
+ }
203
+ export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
204
+ const TAKE_NEIGHBOUR_SENTENCE = {
205
+ "not-applicable": () => "never compared",
206
+ "not-attached": () => "no neighbours attached",
207
+ "none-accepted": () => "no accepted neighbours yet",
208
+ attached: ({ scenes }) => `compared against scene${scenes.length === 1 ? "" : "s"} ${scenes.join(", ")}`,
209
+ };
210
+ export function renderQcTakeHistory(takes) {
211
+ if (!takes || takes.length === 0)
212
+ return [NO_TAKE_HISTORY_LINE];
213
+ const lines = [];
214
+ takes.forEach((take, index) => {
215
+ const say = TAKE_NEIGHBOUR_SENTENCE[take.neighbours.state];
216
+ const parts = [
217
+ take.failCodes.length ? `failed ${take.failCodes.join(", ")}` : "passed",
218
+ ...(take.warnCodes.length ? [`warned ${take.warnCodes.join(", ")}`] : []),
219
+ say(take.neighbours),
220
+ ];
221
+ const bonus = take.kind === "soft-retry" ? " (bonus take)" : "";
222
+ lines.push(`take ${index + 1}${bonus}: ${parts.join("; ")}`);
223
+ for (const { code, wording } of take.judgeWording ?? []) {
224
+ lines.push(` checker said (${code}): ${wording}`);
225
+ }
226
+ });
227
+ return lines;
228
+ }
229
+ function redoLine(lastRedo) {
230
+ return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
231
+ }
232
+ function printDisplacedHistory(lines, lastRedo) {
233
+ const history = lastRedo.displacedHistory;
234
+ if (!history)
235
+ return;
236
+ lines.push(` ${history.title}`);
237
+ for (const line of history.lines)
238
+ lines.push(` ${line}`);
239
+ }
136
240
  export function asVideoRun(data) {
137
241
  const run = (data ?? {});
138
242
  return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
@@ -245,10 +349,20 @@ export function classifyRun(run) {
245
349
  ...(pausedNode.kind === "scene-frames"
246
350
  ? { framesNodeId: pausedNode.nodeId }
247
351
  : {}),
352
+ ...(isShowAd(run) ? { showAd: true } : {}),
353
+ };
354
+ }
355
+ if (parkedAtFinalWatch(run)) {
356
+ const missingScenes = run.nodes
357
+ .filter((n) => n.kind === "video")
358
+ .flatMap((n) => n.missingScenes ?? []);
359
+ const framesNodeId = run.nodes.find((n) => n.kind === "scene-frames")?.nodeId;
360
+ return {
361
+ at: "final-watch",
362
+ ...(missingScenes.length > 0 ? { missingScenes } : {}),
363
+ ...(missingScenes.length > 0 && framesNodeId ? { framesNodeId } : {}),
248
364
  };
249
365
  }
250
- if (parkedAtFinalWatch(run))
251
- return { at: "final-watch" };
252
366
  return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
253
367
  }
254
368
  const active = run.nodes.find((n) => n.status === "running");
@@ -260,7 +374,7 @@ export function hasAttachedCut(items) {
260
374
  export function resolveStop(stop, cutAttached) {
261
375
  if (stop.at !== "final-watch")
262
376
  return stop;
263
- return { at: "final-watch", cutAttached };
377
+ return { ...stop, cutAttached };
264
378
  }
265
379
  export async function resolveStopAtPark(stop, runId, deps) {
266
380
  if (stop.at !== "final-watch")
@@ -295,27 +409,57 @@ export function stepName(kind) {
295
409
  return "A step in this run";
296
410
  return STEP_NAMES[kind] ?? `The ${kind} step`;
297
411
  }
412
+ export function isShowAd(run) {
413
+ return run.moduleOwned === true;
414
+ }
298
415
  export function reviewUrl(dashboardUrl, run) {
299
- if (run.moduleOwned === true)
300
- return `${dashboardUrl}/video?ad=${run._id}`;
301
416
  if (run.workflowId)
302
417
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
303
418
  return `${dashboardUrl}/runs/${run._id}`;
304
419
  }
420
+ function scenesPhrase(scenes) {
421
+ if (scenes.length === 1)
422
+ return `scene ${scenes[0]}`;
423
+ return `scenes ${scenes.slice(0, -1).join(", ")} and ${scenes[scenes.length - 1]}`;
424
+ }
425
+ function missingSceneLines(missing, framesNodeId, runId, runUrl) {
426
+ const scenes = missing.map((m) => m.sceneIndex);
427
+ const lines = [
428
+ `Parked: ${scenesPhrase(scenes)} ${scenes.length === 1 ? "has" : "have"} no clip yet, so the ad isn't complete.`,
429
+ ];
430
+ for (const { sceneIndex, pictureRefused } of missing) {
431
+ if (!pictureRefused) {
432
+ lines.push(`Redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
433
+ continue;
434
+ }
435
+ lines.push(`Scene ${sceneIndex}'s picture was refused by the video service as too close to an existing character, so redraw it before redoing the clip.`);
436
+ lines.push(framesNodeId
437
+ ? `Redraw frame ${sceneIndex}: exodus video retry-frame ${runId} --node ${framesNodeId} --scene ${sceneIndex} --note "<what to change>"`
438
+ : `Redraw frame ${sceneIndex}: on the run page, ${runUrl}`);
439
+ lines.push(`Then redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
440
+ }
441
+ lines.push("The ad can't be approved until every scene has a clip.", `Watch it here: ${runUrl}`);
442
+ return lines;
443
+ }
305
444
  export function stopLines(stop, runId, runUrl) {
306
445
  if (stop.at === "storyboard-gate") {
307
446
  const lines = [
308
447
  "Parked: the storyboard is waiting for your yes.",
309
448
  `Read it: exodus video storyboard ${runId}`,
310
449
  `Approve it: exodus video approve ${runId}`,
311
- `Send back: exodus video flag ${runId} --note "what's wrong"`,
312
450
  ];
451
+ if (stop.showAd) {
452
+ lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
453
+ }
313
454
  if (stop.framesNodeId) {
314
455
  lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
315
456
  }
316
457
  return lines;
317
458
  }
318
459
  if (stop.at === "final-watch") {
460
+ if (stop.missingScenes?.length) {
461
+ return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
462
+ }
319
463
  if (stop.cutAttached === null) {
320
464
  return [
321
465
  "Parked: every piece is made.",
@@ -341,6 +485,13 @@ export function stopLines(stop, runId, runUrl) {
341
485
  }
342
486
  if (stop.at === "failed") {
343
487
  if (stop.repair) {
488
+ if (!stop.step) {
489
+ return [
490
+ "This run stopped at a step that can't be picked back up from here.",
491
+ "Start a fresh run to get this ad made.",
492
+ `Open the run: ${runUrl}`,
493
+ ];
494
+ }
344
495
  return [
345
496
  `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
346
497
  `Try that step again: exodus workflow repair ${runId} retry`,
@@ -366,7 +517,40 @@ export function stopLines(stop, runId, runUrl) {
366
517
  }
367
518
  return [`Working: ${stageWord(stop.stage)}.`];
368
519
  }
369
- export const CAST_LEDGER_BASE = 910000;
520
+ export function failedStoryboardNode(run) {
521
+ return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
522
+ }
523
+ export function errorEchoesReasons(error, record) {
524
+ if (!error || record?.kind !== "rejected")
525
+ return false;
526
+ const reasons = record.reasons ?? [];
527
+ return reasons.length > 0 && reasons.every((reason) => error.includes(reason));
528
+ }
529
+ export function planFailureLines(node, runId) {
530
+ const record = node?.planFailure;
531
+ if (!record || !Array.isArray(record.lines) || !node)
532
+ return [];
533
+ const reasons = record.kind === "rejected" ? (record.reasons ?? []) : [];
534
+ const bodyCount = Math.max(0, record.lines.length - reasons.length);
535
+ const lines = [`What happened to the storyboard step (${node.nodeId}):`];
536
+ record.lines.forEach((line, i) => {
537
+ lines.push(i < bodyCount ? ` ${line}` : ` - ${line}`);
538
+ });
539
+ if (node?.hasRejectedDraft === true) {
540
+ lines.push(" A rejected draft is on file. The system threw it away, so it is a record of what happened and nothing to act on.", ` Read it: exodus video status ${runId} --rejected-draft`);
541
+ }
542
+ return lines;
543
+ }
544
+ export const MUSIC_HEARD_CODE = "music-heard";
545
+ export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
546
+ export function musicBedState(run) {
547
+ if (run.musicBed === undefined)
548
+ return "unknown";
549
+ return run.musicBed ? "on" : "off";
550
+ }
551
+ function isPullSceneIndex(sceneIndex) {
552
+ return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
553
+ }
370
554
  const FALLBACK_EXT = {
371
555
  image: "png",
372
556
  video: "mp4",
@@ -383,6 +567,10 @@ function extFor(url, family) {
383
567
  export function scenePrefix(sceneIndex) {
384
568
  return `scene-${String(sceneIndex).padStart(2, "0")}`;
385
569
  }
570
+ function clipQcOf(item) {
571
+ const artifact = item?.artifact;
572
+ return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
573
+ }
386
574
  function outputsOfNodeKind(run, kind) {
387
575
  return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
388
576
  }
@@ -401,6 +589,8 @@ function clipFromArtifact(sceneIndex, artifact) {
401
589
  revoiced: artifact.revoiced === true,
402
590
  speechTrimmed: artifact.speechTrimmed === true,
403
591
  rawStorageId: artifact.rawStorageId ?? null,
592
+ voiceMode: artifact.voiceMode ?? null,
593
+ voiceFirstBlockedBy: artifact.voiceFirstBlockedBy ?? null,
404
594
  };
405
595
  }
406
596
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -412,7 +602,9 @@ function keyframeDownload(sceneIndex, imageUrl) {
412
602
  function reservedCastFrames(items) {
413
603
  const byIndex = new Map();
414
604
  for (const item of items) {
415
- if (item.itemKind === "frame" && item.sceneIndex >= CAST_LEDGER_BASE) {
605
+ if (item.itemKind === "frame" &&
606
+ item.sceneIndex >= CAST_LEDGER_BASE &&
607
+ item.sceneIndex < SET_OPTION_LEDGER_BASE) {
416
608
  byIndex.set(item.sceneIndex, item);
417
609
  }
418
610
  }
@@ -565,6 +757,8 @@ export function planPull(run, items, opts) {
565
757
  for (const frame of artifact.frames ?? []) {
566
758
  if (!frame.imageUrl)
567
759
  continue;
760
+ if (!isPullSceneIndex(frame.sceneIndex))
761
+ continue;
568
762
  keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
569
763
  }
570
764
  }
@@ -582,7 +776,7 @@ export function planPull(run, items, opts) {
582
776
  };
583
777
  continue;
584
778
  }
585
- if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex < 0)
779
+ if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
586
780
  continue;
587
781
  voiceByScene.set(artifact.sceneIndex, {
588
782
  download: {
@@ -623,6 +817,8 @@ export function planPull(run, items, opts) {
623
817
  }
624
818
  if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
625
819
  continue;
820
+ if (!isPullSceneIndex(artifact.sceneIndex))
821
+ continue;
626
822
  const clip = clipFromArtifact(artifact.sceneIndex, artifact);
627
823
  if (!clip)
628
824
  continue;
@@ -632,6 +828,8 @@ export function planPull(run, items, opts) {
632
828
  const frameItemByScene = new Map();
633
829
  const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
634
830
  for (const item of items) {
831
+ if (!isPullSceneIndex(item.sceneIndex))
832
+ continue;
635
833
  if (item.itemKind === "clip")
636
834
  clipItemByScene.set(item.sceneIndex, item);
637
835
  if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
@@ -663,7 +861,7 @@ export function planPull(run, items, opts) {
663
861
  ...frameItemByScene.keys(),
664
862
  ]),
665
863
  ]
666
- .filter((sceneIndex) => sceneIndex >= 0 && sceneIndex < CAST_LEDGER_BASE)
864
+ .filter((sceneIndex) => isPullSceneIndex(sceneIndex))
667
865
  .sort((a, b) => a - b);
668
866
  const scenes = sceneIndexes.map((sceneIndex) => {
669
867
  const clip = clipByScene.get(sceneIndex);
@@ -698,10 +896,12 @@ export function planPull(run, items, opts) {
698
896
  : "this-file",
699
897
  voice: voice?.download.file ?? null,
700
898
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
701
- qc: clip?.qc ?? null,
899
+ qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
702
900
  revoiced: clip ? clip.revoiced : null,
703
901
  speechTrimmed: clip ? clip.speechTrimmed : null,
704
902
  rawStorageId: clip ? clip.rawStorageId : null,
903
+ voiceMode: clip ? clip.voiceMode : null,
904
+ voiceFirstBlockedBy: clip ? clip.voiceFirstBlockedBy : null,
705
905
  clipStatus: item?.status ?? "missing",
706
906
  error: item?.error ?? null,
707
907
  flagged: item?.flagged === true,
@@ -729,6 +929,10 @@ export function planPull(run, items, opts) {
729
929
  storyboard,
730
930
  reference: reference?.file ?? null,
731
931
  music: music?.file ?? null,
932
+ musicBed: musicBedState(run),
933
+ musicHeardScenes: scenes
934
+ .filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
935
+ .map((scene) => scene.sceneIndex),
732
936
  cast,
733
937
  narration: narrationDownload
734
938
  ? { file: narrationDownload.file, timing: "narration.json" }
@@ -738,6 +942,14 @@ export function planPull(run, items, opts) {
738
942
  ...(run.provenance?.format || run.provenance?.voice
739
943
  ? { provenance: run.provenance }
740
944
  : {}),
945
+ ...(run.videoChoice
946
+ ? {
947
+ videoChoice: {
948
+ videoModel: run.videoChoice.videoModel,
949
+ voiceMode: run.videoChoice.voiceMode,
950
+ },
951
+ }
952
+ : {}),
741
953
  },
742
954
  };
743
955
  }
@@ -763,6 +975,8 @@ export function markPullFailure(manifest, failure) {
763
975
  scene.revoiced = null;
764
976
  scene.speechTrimmed = null;
765
977
  scene.rawStorageId = null;
978
+ scene.voiceMode = null;
979
+ scene.voiceFirstBlockedBy = null;
766
980
  }
767
981
  if (scene.voice === failure.file)
768
982
  scene.voice = null;
@@ -868,7 +1082,7 @@ export async function startFlow(opts, deps) {
868
1082
  showId: opts.showId,
869
1083
  script,
870
1084
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
871
- ...(opts.music === false ? { music: false } : {}),
1085
+ ...(opts.music === undefined ? {} : { music: opts.music }),
872
1086
  });
873
1087
  if (!res.ok)
874
1088
  return errorResult(res, opts.json);
@@ -897,6 +1111,264 @@ export async function startFlow(opts, deps) {
897
1111
  lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
898
1112
  };
899
1113
  }
1114
+ function nonBlank(value) {
1115
+ if (value === undefined || value.trim().length === 0)
1116
+ return undefined;
1117
+ return value;
1118
+ }
1119
+ function isVoiceMode(value) {
1120
+ return VOICE_MODES.some((mode) => mode === value);
1121
+ }
1122
+ function isConceit(value) {
1123
+ return CONCEIT_KEYS.some((key) => key === value);
1124
+ }
1125
+ export function planMusicChoice(occurrences) {
1126
+ let music;
1127
+ for (const { flag, value } of occurrences) {
1128
+ if (flag === "no-music") {
1129
+ music = false;
1130
+ continue;
1131
+ }
1132
+ if (flag !== "music")
1133
+ continue;
1134
+ if (value === undefined || value.startsWith("--")) {
1135
+ music = true;
1136
+ continue;
1137
+ }
1138
+ const word = value.trim().toLowerCase();
1139
+ const literal = word === "true" ? true : word === "false" ? false : undefined;
1140
+ if (literal === undefined) {
1141
+ return {
1142
+ ok: false,
1143
+ line: `video start --music takes true or false, or no value at all (got "${value}").`,
1144
+ };
1145
+ }
1146
+ music = literal;
1147
+ }
1148
+ return { ok: true, music };
1149
+ }
1150
+ function planValueFlag(occurrences, name) {
1151
+ let value;
1152
+ for (const occurrence of occurrences) {
1153
+ if (occurrence.flag !== name)
1154
+ continue;
1155
+ const given = occurrence.value;
1156
+ if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
1157
+ return { ok: false, line: `video start --${name} needs a value.` };
1158
+ }
1159
+ value = given.trim();
1160
+ }
1161
+ return { ok: true, value };
1162
+ }
1163
+ export function planVideoStart(flags, occurrences) {
1164
+ const showId = nonBlank(flagString(flags, "show"));
1165
+ const scriptFile = nonBlank(flagString(flags, "script"));
1166
+ const conceit = nonBlank(flagString(flags, "conceit"));
1167
+ const style = nonBlank(flagString(flags, "style"));
1168
+ if (showId && (conceit || style)) {
1169
+ return {
1170
+ kind: "usage",
1171
+ line: "Pass either --show <id>, or --conceit and --style. Not both.",
1172
+ };
1173
+ }
1174
+ if (!scriptFile) {
1175
+ return {
1176
+ kind: "usage",
1177
+ line: "video start needs --script <file>, a text file of what the ad says.",
1178
+ };
1179
+ }
1180
+ const musicChoice = planMusicChoice(occurrences);
1181
+ if (!musicChoice.ok)
1182
+ return { kind: "usage", line: musicChoice.line };
1183
+ const videoModelFlag = planValueFlag(occurrences, "video-model");
1184
+ if (!videoModelFlag.ok)
1185
+ return { kind: "usage", line: videoModelFlag.line };
1186
+ const voiceFlag = planValueFlag(occurrences, "voice");
1187
+ if (!voiceFlag.ok)
1188
+ return { kind: "usage", line: voiceFlag.line };
1189
+ const videoModel = videoModelFlag.value;
1190
+ const voiceMode = voiceFlag.value;
1191
+ if (showId && (videoModel || voiceMode)) {
1192
+ return {
1193
+ kind: "usage",
1194
+ line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
1195
+ };
1196
+ }
1197
+ if (voiceMode && !isVoiceMode(voiceMode)) {
1198
+ return {
1199
+ kind: "usage",
1200
+ line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
1201
+ };
1202
+ }
1203
+ if (showId) {
1204
+ return {
1205
+ kind: "show",
1206
+ opts: {
1207
+ showId,
1208
+ scriptFile,
1209
+ voicePath: flagString(flags, "voice-path"),
1210
+ music: musicChoice.music,
1211
+ wait: flags["wait"] === true,
1212
+ json: flags["json"] === true,
1213
+ },
1214
+ };
1215
+ }
1216
+ if (!conceit) {
1217
+ return {
1218
+ kind: "usage",
1219
+ line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
1220
+ };
1221
+ }
1222
+ if (!isConceit(conceit)) {
1223
+ return {
1224
+ kind: "usage",
1225
+ line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
1226
+ };
1227
+ }
1228
+ if (!style) {
1229
+ return {
1230
+ kind: "usage",
1231
+ line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
1232
+ };
1233
+ }
1234
+ const direction = nonBlank(flagString(flags, "direction"));
1235
+ const voicePath = nonBlank(flagString(flags, "voice-path"));
1236
+ const opts = {
1237
+ scriptFile,
1238
+ conceit,
1239
+ style,
1240
+ wait: flags["wait"] === true,
1241
+ json: flags["json"] === true,
1242
+ };
1243
+ if (direction)
1244
+ opts.direction = direction;
1245
+ if (voicePath)
1246
+ opts.voicePath = voicePath;
1247
+ if (videoModel)
1248
+ opts.videoModel = videoModel;
1249
+ if (voiceMode && isVoiceMode(voiceMode))
1250
+ opts.voiceMode = voiceMode;
1251
+ if (musicChoice.music !== undefined)
1252
+ opts.music = musicChoice.music;
1253
+ return { kind: "script", opts };
1254
+ }
1255
+ function isRecord(value) {
1256
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1257
+ }
1258
+ function isResolvedCard(value) {
1259
+ if (!isRecord(value))
1260
+ return false;
1261
+ if (!isRecord(value.conceit) || !isRecord(value.style))
1262
+ return false;
1263
+ if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
1264
+ return false;
1265
+ if (typeof value.family !== "string")
1266
+ return false;
1267
+ if (!("wrapper" in value))
1268
+ return false;
1269
+ if (value.wrapper !== null && typeof value.wrapper !== "string")
1270
+ return false;
1271
+ if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
1272
+ return false;
1273
+ return true;
1274
+ }
1275
+ function startedVideoChoice(value) {
1276
+ if (!isRecord(value))
1277
+ return null;
1278
+ if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
1279
+ return null;
1280
+ return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1281
+ }
1282
+ function scriptStartedLines(runId, url, card, video) {
1283
+ return [
1284
+ `Started ad run ${runId}`,
1285
+ ...(card
1286
+ ? [
1287
+ `Conceit: ${card.conceit.label}`,
1288
+ `Family: ${card.family}`,
1289
+ `Wrapper: ${card.wrapper ?? ""}`,
1290
+ `Style: ${card.style.label}`,
1291
+ ]
1292
+ : ["The server did not include a resolved card."]),
1293
+ ...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
1294
+ `Watch it: ${url}`,
1295
+ ];
1296
+ }
1297
+ function waitJsonWithCard(waited, card, video) {
1298
+ const first = waited.lines[0];
1299
+ if (typeof first !== "string")
1300
+ return waited;
1301
+ let parsed;
1302
+ try {
1303
+ parsed = JSON.parse(first);
1304
+ }
1305
+ catch {
1306
+ return waited;
1307
+ }
1308
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1309
+ return waited;
1310
+ return {
1311
+ code: waited.code,
1312
+ lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1313
+ };
1314
+ }
1315
+ export async function startScriptFlow(opts, deps) {
1316
+ let script;
1317
+ try {
1318
+ script = deps.readFile(opts.scriptFile);
1319
+ }
1320
+ catch (e) {
1321
+ return {
1322
+ code: 1,
1323
+ lines: [
1324
+ `Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
1325
+ ],
1326
+ };
1327
+ }
1328
+ if (script.trim().length === 0) {
1329
+ return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
1330
+ }
1331
+ const res = await deps.post(SCRIPT_RUNS_PATH, {
1332
+ script,
1333
+ conceit: opts.conceit,
1334
+ style: opts.style,
1335
+ ...(opts.direction ? { direction: opts.direction } : {}),
1336
+ ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1337
+ ...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
1338
+ ...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
1339
+ ...(opts.music === true ? { music: true } : {}),
1340
+ });
1341
+ if (!res.ok)
1342
+ return errorResult(res, opts.json);
1343
+ const started = res.data;
1344
+ if (!started.runId) {
1345
+ return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
1346
+ }
1347
+ const runId = started.runId;
1348
+ const url = typeof started.url === "string" ? started.url : "";
1349
+ const card = isResolvedCard(started.card) ? started.card : null;
1350
+ const video = startedVideoChoice(started.videoChoice);
1351
+ if (!opts.wait) {
1352
+ return {
1353
+ code: 0,
1354
+ lines: opts.json
1355
+ ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1356
+ : [
1357
+ ...scriptStartedLines(runId, url, card, video),
1358
+ "",
1359
+ "Wait for the storyboard here instead: exodus video start … --wait",
1360
+ `Or check in whenever: exodus video status ${runId}`,
1361
+ ],
1362
+ };
1363
+ }
1364
+ const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1365
+ if (opts.json)
1366
+ return waitJsonWithCard(waited, card, video);
1367
+ return {
1368
+ code: waited.code,
1369
+ lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
1370
+ };
1371
+ }
900
1372
  const POLL_INTERVAL_MS = 5000;
901
1373
  const MAX_POLLS = 720;
902
1374
  export async function waitFlow(runId, opts, deps) {
@@ -956,6 +1428,11 @@ function itemWord(item) {
956
1428
  return "flagged";
957
1429
  return ITEM_STATUS_WORD[item.status] ?? item.status;
958
1430
  }
1431
+ const CARRYING_ON_LINE = "Waiting for a worker to carry on. Everything finished so far is kept — the run picks up from there by itself.";
1432
+ function carryingOn(run) {
1433
+ return (run.status === "queued" &&
1434
+ run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
1435
+ }
959
1436
  export async function statusFlow(runId, json, deps) {
960
1437
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
961
1438
  if (!runRes.ok)
@@ -967,7 +1444,10 @@ export async function statusFlow(runId, json, deps) {
967
1444
  const items = itemsRes.data.items ?? [];
968
1445
  const hasFinal = hasAttachedCut(items);
969
1446
  const stop = resolveStop(classifyRun(run), hasFinal);
970
- const guidance = stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run));
1447
+ const failedStoryboard = failedStoryboardNode(run);
1448
+ const guidance = stopLines(stop.at === "failed" && errorEchoesReasons(stop.error, failedStoryboard?.planFailure)
1449
+ ? { ...stop, error: undefined }
1450
+ : stop, runId, reviewUrl(deps.dashboardUrl, run));
971
1451
  const warnings = run.nodes
972
1452
  .filter((n) => n.warning)
973
1453
  .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
@@ -975,13 +1455,39 @@ export async function statusFlow(runId, json, deps) {
975
1455
  return {
976
1456
  code: 0,
977
1457
  lines: [
978
- JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings, guidance }),
1458
+ JSON.stringify({
1459
+ runId,
1460
+ status: run.status,
1461
+ stop,
1462
+ items,
1463
+ hasFinal,
1464
+ warnings,
1465
+ guidance,
1466
+ ...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
1467
+ ...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
1468
+ ...(failedStoryboard?.planFailure
1469
+ ? { storyboardNodeId: failedStoryboard.nodeId }
1470
+ : {}),
1471
+ ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1472
+ }),
979
1473
  ],
980
1474
  };
981
1475
  }
982
1476
  const revoicedByScene = new Set();
1477
+ const voiceFirstByScene = new Set();
1478
+ const voiceFirstBlockedByScene = new Map();
983
1479
  const timedOnOriginalVoice = new Set();
984
1480
  for (const artifact of outputsOfNodeKind(run, "video")) {
1481
+ if (artifact.type === "video" &&
1482
+ artifact.voiceMode === "voice-first" &&
1483
+ typeof artifact.sceneIndex === "number") {
1484
+ voiceFirstByScene.add(artifact.sceneIndex);
1485
+ }
1486
+ if (artifact.type === "video" &&
1487
+ artifact.voiceFirstBlockedBy?.length &&
1488
+ typeof artifact.sceneIndex === "number") {
1489
+ voiceFirstBlockedByScene.set(artifact.sceneIndex, artifact.voiceFirstBlockedBy);
1490
+ }
985
1491
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
986
1492
  revoicedByScene.add(artifact.sceneIndex);
987
1493
  if ((artifact.words?.length ?? 0) > 0)
@@ -1007,8 +1513,10 @@ export async function statusFlow(runId, json, deps) {
1007
1513
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
1008
1514
  const lines = [
1009
1515
  headline,
1516
+ ...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
1010
1517
  ...guidance,
1011
1518
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1519
+ ...planFailureLines(failedStoryboard, runId),
1012
1520
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
1013
1521
  ];
1014
1522
  if (byScene.size === 0) {
@@ -1019,10 +1527,19 @@ export async function statusFlow(runId, json, deps) {
1019
1527
  for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
1020
1528
  const row = byScene.get(sceneIndex);
1021
1529
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1022
- if (row.clip?.lastRedo)
1023
- lines.push(` clip: ${row.clip.lastRedo.label}`);
1024
- if (row.voiceover?.lastRedo)
1025
- lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1530
+ if (row.clip?.lastRedo) {
1531
+ lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
1532
+ printDisplacedHistory(lines, row.clip.lastRedo);
1533
+ }
1534
+ if (row.clip?.lastRedo || row.clip?.flagged) {
1535
+ for (const line of renderQcTakeHistory(clipQcOf(row.clip))) {
1536
+ lines.push(` ${line}`);
1537
+ }
1538
+ }
1539
+ if (row.voiceover?.lastRedo) {
1540
+ lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
1541
+ printDisplacedHistory(lines, row.voiceover.lastRedo);
1542
+ }
1026
1543
  if (row.frame?.lastRedo)
1027
1544
  lines.push(` picture: ${row.frame.lastRedo.label}`);
1028
1545
  for (const finding of row.clip?.findings ?? []) {
@@ -1039,6 +1556,12 @@ export async function statusFlow(runId, json, deps) {
1039
1556
  lines.push(` ${row.clip.error}`);
1040
1557
  if (revoicedByScene.has(sceneIndex))
1041
1558
  lines.push(" voice: cast voice applied");
1559
+ if (voiceFirstByScene.has(sceneIndex))
1560
+ lines.push(" clip: redone voice first");
1561
+ const blockedBy = voiceFirstBlockedByScene.get(sceneIndex);
1562
+ if (blockedBy) {
1563
+ lines.push(` clip: redone the usual way — ${cantSayNames(blockedBy)}`);
1564
+ }
1042
1565
  if (timedOnOriginalVoice.has(sceneIndex)) {
1043
1566
  lines.push(" word timings: measured on the original voice (close, not frame-exact)");
1044
1567
  }
@@ -1057,6 +1580,45 @@ export async function statusFlow(runId, json, deps) {
1057
1580
  : "Final cut: not uploaded yet.");
1058
1581
  return { code: 0, lines };
1059
1582
  }
1583
+ export async function rejectedDraftFlow(runId, json, deps) {
1584
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1585
+ if (!runRes.ok)
1586
+ return errorResult(runRes, json);
1587
+ const node = failedStoryboardNode(asVideoRun(runRes.data));
1588
+ if (!node) {
1589
+ const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
1590
+ return {
1591
+ code: 0,
1592
+ lines: json ? [JSON.stringify({ runId, nodeId: null, draft: null })] : [note],
1593
+ };
1594
+ }
1595
+ const res = await deps.get(`${REJECTED_DRAFT_PATH}?runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(node.nodeId)}`);
1596
+ if (missingRouteLine(res, "exodus video status --rejected-draft")) {
1597
+ return {
1598
+ code: 1,
1599
+ lines: json
1600
+ ? [JSON.stringify({ ok: false, status: 404, error: REJECTED_DRAFT_NOT_ON_THIS_SERVER })]
1601
+ : [REJECTED_DRAFT_NOT_ON_THIS_SERVER],
1602
+ };
1603
+ }
1604
+ if (!res.ok)
1605
+ return errorResult(res, json);
1606
+ const draft = res.data.draft ?? null;
1607
+ if (json)
1608
+ return { code: 0, lines: [JSON.stringify({ runId, nodeId: node.nodeId, draft })] };
1609
+ if (!draft)
1610
+ return { code: 0, lines: ["No rejected draft was kept for this attempt."] };
1611
+ return {
1612
+ code: 0,
1613
+ lines: [
1614
+ `REJECTED DRAFT — ad run ${runId}, step ${node.nodeId}`,
1615
+ "The system turned this draft down and threw it away. It is kept only as a record of what happened.",
1616
+ "Nothing in it is part of your ad. There is nothing here to act on or edit.",
1617
+ "",
1618
+ ...draft.split("\n"),
1619
+ ],
1620
+ };
1621
+ }
1060
1622
  export async function storyboardFlow(runId, json, deps) {
1061
1623
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
1062
1624
  if (!res.ok)
@@ -1108,7 +1670,14 @@ export async function storyboardFlow(runId, json, deps) {
1108
1670
  }
1109
1671
  }
1110
1672
  }
1111
- lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
1673
+ const runRes = await deps
1674
+ .get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`)
1675
+ .catch(() => null);
1676
+ const showAd = runRes?.ok === true && isShowAd(asVideoRun(runRes.data));
1677
+ lines.push("", `approve with: exodus video approve ${runId}`);
1678
+ if (showAd) {
1679
+ lines.push(`send it back: exodus video flag ${runId} --note "what's wrong"`);
1680
+ }
1112
1681
  if (cards.framesNodeId) {
1113
1682
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
1114
1683
  }
@@ -1136,14 +1705,30 @@ function sceneCardLines(scene) {
1136
1705
  lines.push(` picture: ${scene.frame.status}`);
1137
1706
  return lines;
1138
1707
  }
1139
- export async function approveFlow(runId, json, deps) {
1140
- const res = await deps.post(APPROVE_PATH, { runId });
1141
- if (!res.ok)
1142
- return errorResult(res, json);
1143
- if (json)
1144
- return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
1145
- const voices = res.data
1146
- ?.voices;
1708
+ export async function approveFlow(runId, opts, deps) {
1709
+ const res = await deps.post(APPROVE_PATH, {
1710
+ runId,
1711
+ ...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
1712
+ });
1713
+ if (!res.ok) {
1714
+ const refusal = errorResult(res, opts.json);
1715
+ const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
1716
+ return staleCut
1717
+ ? {
1718
+ ...refusal,
1719
+ lines: [
1720
+ ...refusal.lines,
1721
+ `To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
1722
+ ],
1723
+ }
1724
+ : refusal;
1725
+ }
1726
+ const data = res.data;
1727
+ const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
1728
+ if (opts.json) {
1729
+ return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
1730
+ }
1731
+ const voices = data?.voices;
1147
1732
  const summary = voices
1148
1733
  ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1149
1734
  : [];
@@ -1151,6 +1736,7 @@ export async function approveFlow(runId, json, deps) {
1151
1736
  code: 0,
1152
1737
  lines: [
1153
1738
  "Approved.",
1739
+ ...warnings,
1154
1740
  ...(summary.length > 0 ? ["", ...summary, ""] : []),
1155
1741
  `See what happens next: exodus video status ${runId}`,
1156
1742
  ],
@@ -1208,8 +1794,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1208
1794
  ],
1209
1795
  };
1210
1796
  }
1211
- const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
1212
- "re-cut, and upload again.";
1797
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
1798
+ "exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
1799
+ "or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
1213
1800
  export function planClipRedo(run, items, target) {
1214
1801
  const stop = classifyRun(run);
1215
1802
  const found = findClipRow(run, items, target);
@@ -1275,7 +1862,7 @@ function findClipRow(run, items, target) {
1275
1862
  }
1276
1863
  return { row, uploadedCut };
1277
1864
  }
1278
- export async function retryClipFlow(runId, target, note, json, deps) {
1865
+ export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
1279
1866
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1280
1867
  if (!runRes.ok)
1281
1868
  return errorResult(runRes, json);
@@ -1291,12 +1878,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1291
1878
  lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1292
1879
  };
1293
1880
  }
1294
- const res = await deps.post(SCENE_RETRY_PATH, {
1881
+ const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
1295
1882
  runId,
1296
1883
  nodeId: plan.nodeId,
1297
1884
  sceneIndex: plan.sceneIndex,
1298
1885
  ...(note ? { note } : {}),
1299
1886
  });
1887
+ if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
1888
+ return {
1889
+ code: 1,
1890
+ lines: json
1891
+ ? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
1892
+ : [VOICE_FIRST_NOT_ON_THIS_SERVER],
1893
+ };
1894
+ }
1300
1895
  if (!res.ok)
1301
1896
  return errorResult(res, json);
1302
1897
  const triggerRunId = res.data.triggerRunId;
@@ -1315,6 +1910,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1315
1910
  reviewUrl: review,
1316
1911
  warnings: plan.warnings,
1317
1912
  ...(note ? { note } : {}),
1913
+ ...(voiceFirst ? { voiceMode: "voice-first" } : {}),
1318
1914
  }),
1319
1915
  ],
1320
1916
  };
@@ -1322,9 +1918,19 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1322
1918
  return {
1323
1919
  code: 0,
1324
1920
  lines: [
1325
- `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1921
+ voiceFirst
1922
+ ? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
1923
+ "recorded in the character's own voice, then the clip is made to match it. " +
1924
+ "If the line has a made-up name the recorded voice can't say, the clip may be made " +
1925
+ `the usual way instead, and exodus video status ${runId} will say so.`
1926
+ : `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1326
1927
  `triggerRunId: ${triggerRunId ?? "-"}`,
1327
- "Every other scene stays as it is, and so do the pictures, the voices and the script.",
1928
+ ...(voiceFirst
1929
+ ? [
1930
+ "Every other scene stays as it is, and so do the pictures and the script.",
1931
+ "A speaking character with no voice yet gets one picked and saved for the whole run.",
1932
+ ]
1933
+ : ["Every other scene stays as it is, and so do the pictures, the voices and the script."]),
1328
1934
  ...(note
1329
1935
  ? ["Your note steers how this clip moves. It never changes the words that are spoken."]
1330
1936
  : []),
@@ -1641,6 +2247,13 @@ export function voiceSheetLines(sheet) {
1641
2247
  : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
1642
2248
  return lines;
1643
2249
  }
2250
+ function musicLines(manifest) {
2251
+ const lines = [`music: ${manifest.musicBed}`];
2252
+ if (manifest.musicHeardScenes.length > 0) {
2253
+ lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
2254
+ }
2255
+ return lines;
2256
+ }
1644
2257
  export async function pullFlow(runId, dir, json, deps) {
1645
2258
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1646
2259
  if (!runRes.ok)
@@ -1684,7 +2297,20 @@ export async function pullFlow(runId, dir, json, deps) {
1684
2297
  const lines = [
1685
2298
  `Pulled ${wrote} files into ${dir}`,
1686
2299
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2300
+ ...musicLines(plan.manifest),
1687
2301
  ];
2302
+ const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
2303
+ if (voiceFirst.length > 0) {
2304
+ lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
2305
+ `scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
2306
+ }
2307
+ const redoneUsualWay = plan.manifest.scenes.filter((s) => s.voiceFirstBlockedBy?.length);
2308
+ if (redoneUsualWay.length > 0) {
2309
+ lines.push("", "Asked for voice first, but redone the usual way:");
2310
+ for (const scene of redoneUsualWay) {
2311
+ lines.push(` scene ${scene.sceneIndex}: ${cantSayNames(scene.voiceFirstBlockedBy ?? [])}`);
2312
+ }
2313
+ }
1688
2314
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
1689
2315
  if (flagged.length > 0) {
1690
2316
  lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
@@ -1940,7 +2566,12 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
1940
2566
  const VALUE_FLAGS = new Set([
1941
2567
  "show",
1942
2568
  "script",
2569
+ "conceit",
2570
+ "style",
2571
+ "direction",
1943
2572
  "voice-path",
2573
+ "video-model",
2574
+ "voice",
1944
2575
  "note",
1945
2576
  "out",
1946
2577
  "file",
@@ -1993,20 +2624,12 @@ export async function run(flags, occurrences) {
1993
2624
  if (sub === "shows")
1994
2625
  return printResult(await showsFlow(json, defaultDeps));
1995
2626
  if (sub === "start") {
1996
- const showId = flagString(flags, "show");
1997
- const scriptFile = flagString(flags, "script");
1998
- if (!showId)
1999
- usage("video start needs --show <id>. List them with: exodus video shows");
2000
- if (!scriptFile)
2001
- usage("video start needs --script <file>, a text file of what the ad says.");
2002
- return printResult(await startFlow({
2003
- showId,
2004
- scriptFile,
2005
- voicePath: flagString(flags, "voice-path"),
2006
- music: flags["music"] === false ? false : undefined,
2007
- wait: flags["wait"] === true,
2008
- json,
2009
- }, defaultDeps));
2627
+ const plan = planVideoStart(flags, occurrences);
2628
+ if (plan.kind === "usage")
2629
+ usage(plan.line);
2630
+ if (plan.kind === "show")
2631
+ return printResult(await startFlow(plan.opts, defaultDeps));
2632
+ return printResult(await startScriptFlow(plan.opts, defaultDeps));
2010
2633
  }
2011
2634
  const runId = rest[0];
2012
2635
  const needsRunId = [
@@ -2024,12 +2647,17 @@ export async function run(flags, occurrences) {
2024
2647
  if (needsRunId.includes(sub) && !runId) {
2025
2648
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
2026
2649
  }
2027
- if (sub === "status")
2650
+ if (sub === "status") {
2651
+ if (flags["rejected-draft"] !== undefined && flags["rejected-draft"] !== false) {
2652
+ return printResult(await rejectedDraftFlow(runId, json, defaultDeps));
2653
+ }
2028
2654
  return printResult(await statusFlow(runId, json, defaultDeps));
2655
+ }
2029
2656
  if (sub === "storyboard")
2030
2657
  return printResult(await storyboardFlow(runId, json, defaultDeps));
2031
- if (sub === "approve")
2032
- return printResult(await approveFlow(runId, json, defaultDeps));
2658
+ if (sub === "approve") {
2659
+ return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2660
+ }
2033
2661
  if (sub === "flag") {
2034
2662
  const note = flagString(flags, "note");
2035
2663
  if (!note)
@@ -2059,7 +2687,7 @@ export async function run(flags, occurrences) {
2059
2687
  if (!Number.isInteger(sceneIndex)) {
2060
2688
  usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
2061
2689
  }
2062
- return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
2690
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
2063
2691
  }
2064
2692
  if (sub === "revoice") {
2065
2693
  const sceneRaw = flagString(flags, "scene");