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

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] [--review-storyboard] [--wait] [--json]
77
107
 
78
108
  Options:
79
109
  --out <dir> Folder to write the pulled pieces into (pull)
@@ -88,12 +118,37 @@ 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
+ --review-storyboard Stop at the storyboard so you can approve it yourself
148
+ (start, with --conceit). By default the app approves its
149
+ own storyboard when its checks pass
150
+ --wait Wait until the run needs you (start): at the
151
+ storyboard, or once every piece is made
97
152
  --json Machine-readable output
98
153
  --help, -h Print this help
99
154
 
@@ -108,6 +163,7 @@ Examples:
108
163
  exodus video approve run_123
109
164
  exodus video retry-frame run_123 --node frames-1 --scene 2
110
165
  exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
166
+ exodus video retry-clip run_123 --scene 3 --voice-first
111
167
  exodus video revoice run_123 --all
112
168
  exodus video voices run_123
113
169
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
@@ -117,13 +173,20 @@ Examples:
117
173
  `.trim();
118
174
  const SHOWS_PATH = "/api/v2/shows";
119
175
  const RUNS_PATH = "/api/v2/video/runs";
176
+ const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
120
177
  const RUN_PATH = "/api/v2/workflow";
121
178
  const ITEMS_PATH = "/api/v2/workflow/items";
179
+ const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
180
+ const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
181
+ "with the next server update.";
122
182
  const STORYBOARD_PATH = "/api/v2/video/storyboard";
123
183
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
124
184
  const APPROVE_PATH = "/api/v2/workflow/approve";
125
185
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
126
186
  const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
187
+ const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
188
+ const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
189
+ "was spent. It arrives with the next server update.";
127
190
  const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
128
191
  "was spent. It arrives with the next server update.";
129
192
  const FINAL_PATH = "/api/v2/video/final";
@@ -133,6 +196,51 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
133
196
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
134
197
  return rest;
135
198
  }
199
+ export function qcWithoutJudgeWording(qc) {
200
+ if (!qc.takes)
201
+ return qc;
202
+ return {
203
+ ...qc,
204
+ takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
205
+ };
206
+ }
207
+ export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
208
+ const TAKE_NEIGHBOUR_SENTENCE = {
209
+ "not-applicable": () => "never compared",
210
+ "not-attached": () => "no neighbours attached",
211
+ "none-accepted": () => "no accepted neighbours yet",
212
+ attached: ({ scenes }) => `compared against scene${scenes.length === 1 ? "" : "s"} ${scenes.join(", ")}`,
213
+ };
214
+ export function renderQcTakeHistory(takes) {
215
+ if (!takes || takes.length === 0)
216
+ return [NO_TAKE_HISTORY_LINE];
217
+ const lines = [];
218
+ takes.forEach((take, index) => {
219
+ const say = TAKE_NEIGHBOUR_SENTENCE[take.neighbours.state];
220
+ const parts = [
221
+ take.failCodes.length ? `failed ${take.failCodes.join(", ")}` : "passed",
222
+ ...(take.warnCodes.length ? [`warned ${take.warnCodes.join(", ")}`] : []),
223
+ say(take.neighbours),
224
+ ];
225
+ const bonus = take.kind === "soft-retry" ? " (bonus take)" : "";
226
+ lines.push(`take ${index + 1}${bonus}: ${parts.join("; ")}`);
227
+ for (const { code, wording } of take.judgeWording ?? []) {
228
+ lines.push(` checker said (${code}): ${wording}`);
229
+ }
230
+ });
231
+ return lines;
232
+ }
233
+ function redoLine(lastRedo) {
234
+ return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
235
+ }
236
+ function printDisplacedHistory(lines, lastRedo) {
237
+ const history = lastRedo.displacedHistory;
238
+ if (!history)
239
+ return;
240
+ lines.push(` ${history.title}`);
241
+ for (const line of history.lines)
242
+ lines.push(` ${line}`);
243
+ }
136
244
  export function asVideoRun(data) {
137
245
  const run = (data ?? {});
138
246
  return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
@@ -220,6 +328,14 @@ function parkedAtFinalWatch(run) {
220
328
  return (!parkedByBuilderCheckpoint(run) &&
221
329
  run.nodes.some((n) => n.kind === "video" && n.status === "done"));
222
330
  }
331
+ function heldReason(run) {
332
+ const verdict = run.storyboardAutoApproval;
333
+ if (verdict?.outcome !== "held")
334
+ return undefined;
335
+ const reason = verdict.reason?.trim() ||
336
+ "The app didn't approve this storyboard by itself because its checks found a problem.";
337
+ return `${reason} (checked ${new Date(verdict.at).toISOString()})`;
338
+ }
223
339
  export function classifyRun(run) {
224
340
  if (run.status === "failed")
225
341
  return { at: "failed", error: run.error };
@@ -239,16 +355,33 @@ export function classifyRun(run) {
239
355
  }
240
356
  const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
241
357
  if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
358
+ const held = heldReason(run);
242
359
  return {
243
360
  at: "storyboard-gate",
244
361
  nodeId: pausedNode.nodeId,
245
362
  ...(pausedNode.kind === "scene-frames"
246
363
  ? { framesNodeId: pausedNode.nodeId }
247
364
  : {}),
365
+ ...(isShowAd(run) ? { showAd: true } : {}),
366
+ ...(held ? { heldReason: held } : {}),
367
+ };
368
+ }
369
+ if (parkedAtFinalWatch(run)) {
370
+ const autoRedoScenes = run.nodes
371
+ .filter((n) => n.kind === "video")
372
+ .flatMap((n) => n.autoRedoScenes ?? []);
373
+ if (autoRedoScenes.length > 0)
374
+ return { at: "running", stage: "video", autoRedoScenes };
375
+ const missingScenes = run.nodes
376
+ .filter((n) => n.kind === "video")
377
+ .flatMap((n) => n.missingScenes ?? []);
378
+ const framesNodeId = run.nodes.find((n) => n.kind === "scene-frames")?.nodeId;
379
+ return {
380
+ at: "final-watch",
381
+ ...(missingScenes.length > 0 ? { missingScenes } : {}),
382
+ ...(missingScenes.length > 0 && framesNodeId ? { framesNodeId } : {}),
248
383
  };
249
384
  }
250
- if (parkedAtFinalWatch(run))
251
- return { at: "final-watch" };
252
385
  return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
253
386
  }
254
387
  const active = run.nodes.find((n) => n.status === "running");
@@ -257,10 +390,22 @@ export function classifyRun(run) {
257
390
  export function hasAttachedCut(items) {
258
391
  return items.some((i) => i.itemKind === "final" && i.status === "done");
259
392
  }
393
+ export function scenesBeingRedone(run, items) {
394
+ const scenes = new Set(run.nodes.filter((n) => n.kind === "video").flatMap((n) => n.autoRedoScenes ?? []));
395
+ for (const i of items) {
396
+ if (i.itemKind === "clip" &&
397
+ i.status === "running" &&
398
+ (i.attempt ?? 1) > 1 &&
399
+ i.staleClaim !== true) {
400
+ scenes.add(i.sceneIndex);
401
+ }
402
+ }
403
+ return [...scenes].sort((a, b) => a - b);
404
+ }
260
405
  export function resolveStop(stop, cutAttached) {
261
406
  if (stop.at !== "final-watch")
262
407
  return stop;
263
- return { at: "final-watch", cutAttached };
408
+ return { ...stop, cutAttached };
264
409
  }
265
410
  export async function resolveStopAtPark(stop, runId, deps) {
266
411
  if (stop.at !== "final-watch")
@@ -295,27 +440,58 @@ export function stepName(kind) {
295
440
  return "A step in this run";
296
441
  return STEP_NAMES[kind] ?? `The ${kind} step`;
297
442
  }
443
+ export function isShowAd(run) {
444
+ return run.moduleOwned === true;
445
+ }
298
446
  export function reviewUrl(dashboardUrl, run) {
299
- if (run.moduleOwned === true)
300
- return `${dashboardUrl}/video?ad=${run._id}`;
301
447
  if (run.workflowId)
302
448
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
303
449
  return `${dashboardUrl}/runs/${run._id}`;
304
450
  }
451
+ function scenesPhrase(scenes) {
452
+ if (scenes.length === 1)
453
+ return `scene ${scenes[0]}`;
454
+ return `scenes ${scenes.slice(0, -1).join(", ")} and ${scenes[scenes.length - 1]}`;
455
+ }
456
+ function missingSceneLines(missing, framesNodeId, runId, runUrl) {
457
+ const scenes = missing.map((m) => m.sceneIndex);
458
+ const lines = [
459
+ `Parked: ${scenesPhrase(scenes)} ${scenes.length === 1 ? "has" : "have"} no clip yet, so the ad isn't complete.`,
460
+ ];
461
+ for (const { sceneIndex, pictureRefused } of missing) {
462
+ if (!pictureRefused) {
463
+ lines.push(`Redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
464
+ continue;
465
+ }
466
+ 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.`);
467
+ lines.push(framesNodeId
468
+ ? `Redraw frame ${sceneIndex}: exodus video retry-frame ${runId} --node ${framesNodeId} --scene ${sceneIndex} --note "<what to change>"`
469
+ : `Redraw frame ${sceneIndex}: on the run page, ${runUrl}`);
470
+ lines.push(`Then redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
471
+ }
472
+ lines.push("The ad can't be approved until every scene has a clip.", `Watch it here: ${runUrl}`);
473
+ return lines;
474
+ }
305
475
  export function stopLines(stop, runId, runUrl) {
306
476
  if (stop.at === "storyboard-gate") {
307
477
  const lines = [
478
+ ...(stop.heldReason ? [stop.heldReason] : []),
308
479
  "Parked: the storyboard is waiting for your yes.",
309
480
  `Read it: exodus video storyboard ${runId}`,
310
481
  `Approve it: exodus video approve ${runId}`,
311
- `Send back: exodus video flag ${runId} --note "what's wrong"`,
312
482
  ];
483
+ if (stop.showAd) {
484
+ lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
485
+ }
313
486
  if (stop.framesNodeId) {
314
487
  lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
315
488
  }
316
489
  return lines;
317
490
  }
318
491
  if (stop.at === "final-watch") {
492
+ if (stop.missingScenes?.length) {
493
+ return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
494
+ }
319
495
  if (stop.cutAttached === null) {
320
496
  return [
321
497
  "Parked: every piece is made.",
@@ -341,6 +517,13 @@ export function stopLines(stop, runId, runUrl) {
341
517
  }
342
518
  if (stop.at === "failed") {
343
519
  if (stop.repair) {
520
+ if (!stop.step) {
521
+ return [
522
+ "This run stopped at a step that can't be picked back up from here.",
523
+ "Start a fresh run to get this ad made.",
524
+ `Open the run: ${runUrl}`,
525
+ ];
526
+ }
344
527
  return [
345
528
  `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
346
529
  `Try that step again: exodus workflow repair ${runId} retry`,
@@ -364,9 +547,49 @@ export function stopLines(stop, runId, runUrl) {
364
547
  `Open it: ${runUrl}`,
365
548
  ];
366
549
  }
550
+ if (stop.autoRedoScenes?.length)
551
+ return [autoRedoLine(stop.autoRedoScenes)];
367
552
  return [`Working: ${stageWord(stop.stage)}.`];
368
553
  }
369
- export const CAST_LEDGER_BASE = 910000;
554
+ function autoRedoLine(scenes) {
555
+ const one = scenes.length === 1;
556
+ return (`Working: the app is redoing ${scenesPhrase(scenes)} on ${one ? "its" : "their"} own because ` +
557
+ `${one ? "it" : "they"} repeated words. The ad isn't ready to watch until ${one ? "it lands" : "they land"}.`);
558
+ }
559
+ export function failedStoryboardNode(run) {
560
+ return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
561
+ }
562
+ export function errorEchoesReasons(error, record) {
563
+ if (!error || record?.kind !== "rejected")
564
+ return false;
565
+ const reasons = record.reasons ?? [];
566
+ return reasons.length > 0 && reasons.every((reason) => error.includes(reason));
567
+ }
568
+ export function planFailureLines(node, runId) {
569
+ const record = node?.planFailure;
570
+ if (!record || !Array.isArray(record.lines) || !node)
571
+ return [];
572
+ const reasons = record.kind === "rejected" ? (record.reasons ?? []) : [];
573
+ const bodyCount = Math.max(0, record.lines.length - reasons.length);
574
+ const lines = [`What happened to the storyboard step (${node.nodeId}):`];
575
+ record.lines.forEach((line, i) => {
576
+ lines.push(i < bodyCount ? ` ${line}` : ` - ${line}`);
577
+ });
578
+ if (node?.hasRejectedDraft === true) {
579
+ 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`);
580
+ }
581
+ return lines;
582
+ }
583
+ export const MUSIC_HEARD_CODE = "music-heard";
584
+ export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
585
+ export function musicBedState(run) {
586
+ if (run.musicBed === undefined)
587
+ return "unknown";
588
+ return run.musicBed ? "on" : "off";
589
+ }
590
+ function isPullSceneIndex(sceneIndex) {
591
+ return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
592
+ }
370
593
  const FALLBACK_EXT = {
371
594
  image: "png",
372
595
  video: "mp4",
@@ -383,6 +606,10 @@ function extFor(url, family) {
383
606
  export function scenePrefix(sceneIndex) {
384
607
  return `scene-${String(sceneIndex).padStart(2, "0")}`;
385
608
  }
609
+ function clipQcOf(item) {
610
+ const artifact = item?.artifact;
611
+ return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
612
+ }
386
613
  function outputsOfNodeKind(run, kind) {
387
614
  return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
388
615
  }
@@ -401,6 +628,8 @@ function clipFromArtifact(sceneIndex, artifact) {
401
628
  revoiced: artifact.revoiced === true,
402
629
  speechTrimmed: artifact.speechTrimmed === true,
403
630
  rawStorageId: artifact.rawStorageId ?? null,
631
+ voiceMode: artifact.voiceMode ?? null,
632
+ voiceFirstBlockedBy: artifact.voiceFirstBlockedBy ?? null,
404
633
  };
405
634
  }
406
635
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -412,7 +641,9 @@ function keyframeDownload(sceneIndex, imageUrl) {
412
641
  function reservedCastFrames(items) {
413
642
  const byIndex = new Map();
414
643
  for (const item of items) {
415
- if (item.itemKind === "frame" && item.sceneIndex >= CAST_LEDGER_BASE) {
644
+ if (item.itemKind === "frame" &&
645
+ item.sceneIndex >= CAST_LEDGER_BASE &&
646
+ item.sceneIndex < SET_OPTION_LEDGER_BASE) {
416
647
  byIndex.set(item.sceneIndex, item);
417
648
  }
418
649
  }
@@ -565,6 +796,8 @@ export function planPull(run, items, opts) {
565
796
  for (const frame of artifact.frames ?? []) {
566
797
  if (!frame.imageUrl)
567
798
  continue;
799
+ if (!isPullSceneIndex(frame.sceneIndex))
800
+ continue;
568
801
  keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
569
802
  }
570
803
  }
@@ -582,7 +815,7 @@ export function planPull(run, items, opts) {
582
815
  };
583
816
  continue;
584
817
  }
585
- if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex < 0)
818
+ if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
586
819
  continue;
587
820
  voiceByScene.set(artifact.sceneIndex, {
588
821
  download: {
@@ -623,6 +856,8 @@ export function planPull(run, items, opts) {
623
856
  }
624
857
  if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
625
858
  continue;
859
+ if (!isPullSceneIndex(artifact.sceneIndex))
860
+ continue;
626
861
  const clip = clipFromArtifact(artifact.sceneIndex, artifact);
627
862
  if (!clip)
628
863
  continue;
@@ -632,6 +867,8 @@ export function planPull(run, items, opts) {
632
867
  const frameItemByScene = new Map();
633
868
  const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
634
869
  for (const item of items) {
870
+ if (!isPullSceneIndex(item.sceneIndex))
871
+ continue;
635
872
  if (item.itemKind === "clip")
636
873
  clipItemByScene.set(item.sceneIndex, item);
637
874
  if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
@@ -654,6 +891,9 @@ export function planPull(run, items, opts) {
654
891
  continue;
655
892
  keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
656
893
  }
894
+ const pendingRedo = scenesBeingRedone(run, items);
895
+ for (const sceneIndex of pendingRedo)
896
+ clipByScene.delete(sceneIndex);
657
897
  const sceneIndexes = [
658
898
  ...new Set([
659
899
  ...clipByScene.keys(),
@@ -663,7 +903,7 @@ export function planPull(run, items, opts) {
663
903
  ...frameItemByScene.keys(),
664
904
  ]),
665
905
  ]
666
- .filter((sceneIndex) => sceneIndex >= 0 && sceneIndex < CAST_LEDGER_BASE)
906
+ .filter((sceneIndex) => isPullSceneIndex(sceneIndex))
667
907
  .sort((a, b) => a - b);
668
908
  const scenes = sceneIndexes.map((sceneIndex) => {
669
909
  const clip = clipByScene.get(sceneIndex);
@@ -698,10 +938,12 @@ export function planPull(run, items, opts) {
698
938
  : "this-file",
699
939
  voice: voice?.download.file ?? null,
700
940
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
701
- qc: clip?.qc ?? null,
941
+ qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
702
942
  revoiced: clip ? clip.revoiced : null,
703
943
  speechTrimmed: clip ? clip.speechTrimmed : null,
704
944
  rawStorageId: clip ? clip.rawStorageId : null,
945
+ voiceMode: clip ? clip.voiceMode : null,
946
+ voiceFirstBlockedBy: clip ? clip.voiceFirstBlockedBy : null,
705
947
  clipStatus: item?.status ?? "missing",
706
948
  error: item?.error ?? null,
707
949
  flagged: item?.flagged === true,
@@ -729,6 +971,11 @@ export function planPull(run, items, opts) {
729
971
  storyboard,
730
972
  reference: reference?.file ?? null,
731
973
  music: music?.file ?? null,
974
+ musicBed: musicBedState(run),
975
+ musicHeardScenes: scenes
976
+ .filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
977
+ .map((scene) => scene.sceneIndex),
978
+ pendingRedo,
732
979
  cast,
733
980
  narration: narrationDownload
734
981
  ? { file: narrationDownload.file, timing: "narration.json" }
@@ -738,6 +985,14 @@ export function planPull(run, items, opts) {
738
985
  ...(run.provenance?.format || run.provenance?.voice
739
986
  ? { provenance: run.provenance }
740
987
  : {}),
988
+ ...(run.videoChoice
989
+ ? {
990
+ videoChoice: {
991
+ videoModel: run.videoChoice.videoModel,
992
+ voiceMode: run.videoChoice.voiceMode,
993
+ },
994
+ }
995
+ : {}),
741
996
  },
742
997
  };
743
998
  }
@@ -763,6 +1018,8 @@ export function markPullFailure(manifest, failure) {
763
1018
  scene.revoiced = null;
764
1019
  scene.speechTrimmed = null;
765
1020
  scene.rawStorageId = null;
1021
+ scene.voiceMode = null;
1022
+ scene.voiceFirstBlockedBy = null;
766
1023
  }
767
1024
  if (scene.voice === failure.file)
768
1025
  scene.voice = null;
@@ -868,7 +1125,7 @@ export async function startFlow(opts, deps) {
868
1125
  showId: opts.showId,
869
1126
  script,
870
1127
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
871
- ...(opts.music === false ? { music: false } : {}),
1128
+ ...(opts.music === undefined ? {} : { music: opts.music }),
872
1129
  });
873
1130
  if (!res.ok)
874
1131
  return errorResult(res, opts.json);
@@ -897,13 +1154,287 @@ export async function startFlow(opts, deps) {
897
1154
  lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
898
1155
  };
899
1156
  }
1157
+ function nonBlank(value) {
1158
+ if (value === undefined || value.trim().length === 0)
1159
+ return undefined;
1160
+ return value;
1161
+ }
1162
+ function isVoiceMode(value) {
1163
+ return VOICE_MODES.some((mode) => mode === value);
1164
+ }
1165
+ function isConceit(value) {
1166
+ return CONCEIT_KEYS.some((key) => key === value);
1167
+ }
1168
+ export function planMusicChoice(occurrences) {
1169
+ let music;
1170
+ for (const { flag, value } of occurrences) {
1171
+ if (flag === "no-music") {
1172
+ music = false;
1173
+ continue;
1174
+ }
1175
+ if (flag !== "music")
1176
+ continue;
1177
+ if (value === undefined || value.startsWith("--")) {
1178
+ music = true;
1179
+ continue;
1180
+ }
1181
+ const word = value.trim().toLowerCase();
1182
+ const literal = word === "true" ? true : word === "false" ? false : undefined;
1183
+ if (literal === undefined) {
1184
+ return {
1185
+ ok: false,
1186
+ line: `video start --music takes true or false, or no value at all (got "${value}").`,
1187
+ };
1188
+ }
1189
+ music = literal;
1190
+ }
1191
+ return { ok: true, music };
1192
+ }
1193
+ function planValueFlag(occurrences, name) {
1194
+ let value;
1195
+ for (const occurrence of occurrences) {
1196
+ if (occurrence.flag !== name)
1197
+ continue;
1198
+ const given = occurrence.value;
1199
+ if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
1200
+ return { ok: false, line: `video start --${name} needs a value.` };
1201
+ }
1202
+ value = given.trim();
1203
+ }
1204
+ return { ok: true, value };
1205
+ }
1206
+ export function planVideoStart(flags, occurrences) {
1207
+ const showId = nonBlank(flagString(flags, "show"));
1208
+ const scriptFile = nonBlank(flagString(flags, "script"));
1209
+ const conceit = nonBlank(flagString(flags, "conceit"));
1210
+ const style = nonBlank(flagString(flags, "style"));
1211
+ if (showId && (conceit || style)) {
1212
+ return {
1213
+ kind: "usage",
1214
+ line: "Pass either --show <id>, or --conceit and --style. Not both.",
1215
+ };
1216
+ }
1217
+ if (!scriptFile) {
1218
+ return {
1219
+ kind: "usage",
1220
+ line: "video start needs --script <file>, a text file of what the ad says.",
1221
+ };
1222
+ }
1223
+ const musicChoice = planMusicChoice(occurrences);
1224
+ if (!musicChoice.ok)
1225
+ return { kind: "usage", line: musicChoice.line };
1226
+ const videoModelFlag = planValueFlag(occurrences, "video-model");
1227
+ if (!videoModelFlag.ok)
1228
+ return { kind: "usage", line: videoModelFlag.line };
1229
+ const voiceFlag = planValueFlag(occurrences, "voice");
1230
+ if (!voiceFlag.ok)
1231
+ return { kind: "usage", line: voiceFlag.line };
1232
+ const videoModel = videoModelFlag.value;
1233
+ const voiceMode = voiceFlag.value;
1234
+ const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
1235
+ if (showId && reviewStoryboard) {
1236
+ return {
1237
+ kind: "usage",
1238
+ line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
1239
+ };
1240
+ }
1241
+ if (showId && (videoModel || voiceMode)) {
1242
+ return {
1243
+ kind: "usage",
1244
+ line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
1245
+ };
1246
+ }
1247
+ if (voiceMode && !isVoiceMode(voiceMode)) {
1248
+ return {
1249
+ kind: "usage",
1250
+ line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
1251
+ };
1252
+ }
1253
+ if (showId) {
1254
+ return {
1255
+ kind: "show",
1256
+ opts: {
1257
+ showId,
1258
+ scriptFile,
1259
+ voicePath: flagString(flags, "voice-path"),
1260
+ music: musicChoice.music,
1261
+ wait: flags["wait"] === true,
1262
+ json: flags["json"] === true,
1263
+ },
1264
+ };
1265
+ }
1266
+ if (!conceit) {
1267
+ return {
1268
+ kind: "usage",
1269
+ line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
1270
+ };
1271
+ }
1272
+ if (!isConceit(conceit)) {
1273
+ return {
1274
+ kind: "usage",
1275
+ line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
1276
+ };
1277
+ }
1278
+ if (!style) {
1279
+ return {
1280
+ kind: "usage",
1281
+ line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
1282
+ };
1283
+ }
1284
+ const direction = nonBlank(flagString(flags, "direction"));
1285
+ const voicePath = nonBlank(flagString(flags, "voice-path"));
1286
+ const opts = {
1287
+ scriptFile,
1288
+ conceit,
1289
+ style,
1290
+ wait: flags["wait"] === true,
1291
+ json: flags["json"] === true,
1292
+ };
1293
+ if (direction)
1294
+ opts.direction = direction;
1295
+ if (voicePath)
1296
+ opts.voicePath = voicePath;
1297
+ if (videoModel)
1298
+ opts.videoModel = videoModel;
1299
+ if (voiceMode && isVoiceMode(voiceMode))
1300
+ opts.voiceMode = voiceMode;
1301
+ if (musicChoice.music !== undefined)
1302
+ opts.music = musicChoice.music;
1303
+ if (reviewStoryboard)
1304
+ opts.reviewStoryboard = true;
1305
+ return { kind: "script", opts };
1306
+ }
1307
+ function isRecord(value) {
1308
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1309
+ }
1310
+ function isResolvedCard(value) {
1311
+ if (!isRecord(value))
1312
+ return false;
1313
+ if (!isRecord(value.conceit) || !isRecord(value.style))
1314
+ return false;
1315
+ if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
1316
+ return false;
1317
+ if (typeof value.family !== "string")
1318
+ return false;
1319
+ if (!("wrapper" in value))
1320
+ return false;
1321
+ if (value.wrapper !== null && typeof value.wrapper !== "string")
1322
+ return false;
1323
+ if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
1324
+ return false;
1325
+ return true;
1326
+ }
1327
+ function startedVideoChoice(value) {
1328
+ if (!isRecord(value))
1329
+ return null;
1330
+ if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
1331
+ return null;
1332
+ return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1333
+ }
1334
+ function scriptStartedLines(runId, url, card, video) {
1335
+ return [
1336
+ `Started ad run ${runId}`,
1337
+ ...(card
1338
+ ? [
1339
+ `Conceit: ${card.conceit.label}`,
1340
+ `Family: ${card.family}`,
1341
+ `Wrapper: ${card.wrapper ?? ""}`,
1342
+ `Style: ${card.style.label}`,
1343
+ ]
1344
+ : ["The server did not include a resolved card."]),
1345
+ ...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
1346
+ `Watch it: ${url}`,
1347
+ ];
1348
+ }
1349
+ function waitJsonWithCard(waited, card, video) {
1350
+ const first = waited.lines[0];
1351
+ if (typeof first !== "string")
1352
+ return waited;
1353
+ let parsed;
1354
+ try {
1355
+ parsed = JSON.parse(first);
1356
+ }
1357
+ catch {
1358
+ return waited;
1359
+ }
1360
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1361
+ return waited;
1362
+ return {
1363
+ code: waited.code,
1364
+ lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1365
+ };
1366
+ }
1367
+ export async function startScriptFlow(opts, deps) {
1368
+ let script;
1369
+ try {
1370
+ script = deps.readFile(opts.scriptFile);
1371
+ }
1372
+ catch (e) {
1373
+ return {
1374
+ code: 1,
1375
+ lines: [
1376
+ `Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
1377
+ ],
1378
+ };
1379
+ }
1380
+ if (script.trim().length === 0) {
1381
+ return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
1382
+ }
1383
+ const res = await deps.post(SCRIPT_RUNS_PATH, {
1384
+ script,
1385
+ conceit: opts.conceit,
1386
+ style: opts.style,
1387
+ ...(opts.direction ? { direction: opts.direction } : {}),
1388
+ ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1389
+ ...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
1390
+ ...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
1391
+ ...(opts.music === true ? { music: true } : {}),
1392
+ ...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
1393
+ });
1394
+ if (!res.ok)
1395
+ return errorResult(res, opts.json);
1396
+ const started = res.data;
1397
+ if (!started.runId) {
1398
+ return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
1399
+ }
1400
+ const runId = started.runId;
1401
+ const url = typeof started.url === "string" ? started.url : "";
1402
+ const card = isResolvedCard(started.card) ? started.card : null;
1403
+ const video = startedVideoChoice(started.videoChoice);
1404
+ if (!opts.wait) {
1405
+ return {
1406
+ code: 0,
1407
+ lines: opts.json
1408
+ ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1409
+ : [
1410
+ ...scriptStartedLines(runId, url, card, video),
1411
+ "",
1412
+ ...(opts.reviewStoryboard
1413
+ ? ["Wait for the storyboard here instead: exodus video start … --wait"]
1414
+ : [
1415
+ "The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
1416
+ "Wait here until it needs you: exodus video start … --wait",
1417
+ ]),
1418
+ `Or check in whenever: exodus video status ${runId}`,
1419
+ ],
1420
+ };
1421
+ }
1422
+ const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1423
+ if (opts.json)
1424
+ return waitJsonWithCard(waited, card, video);
1425
+ return {
1426
+ code: waited.code,
1427
+ lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
1428
+ };
1429
+ }
900
1430
  const POLL_INTERVAL_MS = 5000;
901
1431
  const MAX_POLLS = 720;
902
1432
  export async function waitFlow(runId, opts, deps) {
903
1433
  const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
904
1434
  const maxPolls = opts.maxPolls ?? MAX_POLLS;
905
1435
  const lines = [];
906
- let lastStage = null;
1436
+ let lastProgress = null;
1437
+ let redoSeen = false;
907
1438
  for (let poll = 0; poll < maxPolls; poll++) {
908
1439
  if (poll > 0)
909
1440
  await deps.sleep(interval);
@@ -915,14 +1446,35 @@ export async function waitFlow(runId, opts, deps) {
915
1446
  const run = asVideoRun(res.data);
916
1447
  const stop = classifyRun(run);
917
1448
  if (stop.at === "running") {
918
- if (stop.stage !== lastStage) {
919
- lastStage = stop.stage;
1449
+ const progress = stop.autoRedoScenes?.length
1450
+ ? autoRedoLine(stop.autoRedoScenes)
1451
+ : `Working: ${stageWord(stop.stage)}…`;
1452
+ if (progress !== lastProgress) {
1453
+ lastProgress = progress;
920
1454
  if (!opts.json)
921
- lines.push(`Working: ${stageWord(stop.stage)}…`);
1455
+ lines.push(progress);
922
1456
  }
923
1457
  continue;
924
1458
  }
925
- const resolved = await resolveStopAtPark(stop, runId, deps);
1459
+ let items = null;
1460
+ if (stop.at === "final-watch") {
1461
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1462
+ items = itemsRes.ok ? (itemsRes.data.items ?? []) : null;
1463
+ if (items === null && redoSeen)
1464
+ continue;
1465
+ const redoing = items ? scenesBeingRedone(run, items) : [];
1466
+ redoSeen = redoing.length > 0;
1467
+ if (redoSeen) {
1468
+ const progress = `Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`;
1469
+ if (progress !== lastProgress) {
1470
+ lastProgress = progress;
1471
+ if (!opts.json)
1472
+ lines.push(progress);
1473
+ }
1474
+ continue;
1475
+ }
1476
+ }
1477
+ const resolved = resolveStop(stop, items ? hasAttachedCut(items) : null);
926
1478
  if (opts.json) {
927
1479
  return {
928
1480
  code: stop.at === "failed" ? 1 : 0,
@@ -956,6 +1508,11 @@ function itemWord(item) {
956
1508
  return "flagged";
957
1509
  return ITEM_STATUS_WORD[item.status] ?? item.status;
958
1510
  }
1511
+ 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.";
1512
+ function carryingOn(run) {
1513
+ return (run.status === "queued" &&
1514
+ run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
1515
+ }
959
1516
  export async function statusFlow(runId, json, deps) {
960
1517
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
961
1518
  if (!runRes.ok)
@@ -967,7 +1524,10 @@ export async function statusFlow(runId, json, deps) {
967
1524
  const items = itemsRes.data.items ?? [];
968
1525
  const hasFinal = hasAttachedCut(items);
969
1526
  const stop = resolveStop(classifyRun(run), hasFinal);
970
- const guidance = stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run));
1527
+ const failedStoryboard = failedStoryboardNode(run);
1528
+ const guidance = stopLines(stop.at === "failed" && errorEchoesReasons(stop.error, failedStoryboard?.planFailure)
1529
+ ? { ...stop, error: undefined }
1530
+ : stop, runId, reviewUrl(deps.dashboardUrl, run));
971
1531
  const warnings = run.nodes
972
1532
  .filter((n) => n.warning)
973
1533
  .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
@@ -975,13 +1535,39 @@ export async function statusFlow(runId, json, deps) {
975
1535
  return {
976
1536
  code: 0,
977
1537
  lines: [
978
- JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings, guidance }),
1538
+ JSON.stringify({
1539
+ runId,
1540
+ status: run.status,
1541
+ stop,
1542
+ items,
1543
+ hasFinal,
1544
+ warnings,
1545
+ guidance,
1546
+ ...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
1547
+ ...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
1548
+ ...(failedStoryboard?.planFailure
1549
+ ? { storyboardNodeId: failedStoryboard.nodeId }
1550
+ : {}),
1551
+ ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1552
+ }),
979
1553
  ],
980
1554
  };
981
1555
  }
982
1556
  const revoicedByScene = new Set();
1557
+ const voiceFirstByScene = new Set();
1558
+ const voiceFirstBlockedByScene = new Map();
983
1559
  const timedOnOriginalVoice = new Set();
984
1560
  for (const artifact of outputsOfNodeKind(run, "video")) {
1561
+ if (artifact.type === "video" &&
1562
+ artifact.voiceMode === "voice-first" &&
1563
+ typeof artifact.sceneIndex === "number") {
1564
+ voiceFirstByScene.add(artifact.sceneIndex);
1565
+ }
1566
+ if (artifact.type === "video" &&
1567
+ artifact.voiceFirstBlockedBy?.length &&
1568
+ typeof artifact.sceneIndex === "number") {
1569
+ voiceFirstBlockedByScene.set(artifact.sceneIndex, artifact.voiceFirstBlockedBy);
1570
+ }
985
1571
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
986
1572
  revoicedByScene.add(artifact.sceneIndex);
987
1573
  if ((artifact.words?.length ?? 0) > 0)
@@ -1007,8 +1593,10 @@ export async function statusFlow(runId, json, deps) {
1007
1593
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
1008
1594
  const lines = [
1009
1595
  headline,
1596
+ ...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
1010
1597
  ...guidance,
1011
1598
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1599
+ ...planFailureLines(failedStoryboard, runId),
1012
1600
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
1013
1601
  ];
1014
1602
  if (byScene.size === 0) {
@@ -1019,10 +1607,19 @@ export async function statusFlow(runId, json, deps) {
1019
1607
  for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
1020
1608
  const row = byScene.get(sceneIndex);
1021
1609
  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}`);
1610
+ if (row.clip?.lastRedo) {
1611
+ lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
1612
+ printDisplacedHistory(lines, row.clip.lastRedo);
1613
+ }
1614
+ if (row.clip?.lastRedo || row.clip?.flagged) {
1615
+ for (const line of renderQcTakeHistory(clipQcOf(row.clip))) {
1616
+ lines.push(` ${line}`);
1617
+ }
1618
+ }
1619
+ if (row.voiceover?.lastRedo) {
1620
+ lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
1621
+ printDisplacedHistory(lines, row.voiceover.lastRedo);
1622
+ }
1026
1623
  if (row.frame?.lastRedo)
1027
1624
  lines.push(` picture: ${row.frame.lastRedo.label}`);
1028
1625
  for (const finding of row.clip?.findings ?? []) {
@@ -1039,6 +1636,12 @@ export async function statusFlow(runId, json, deps) {
1039
1636
  lines.push(` ${row.clip.error}`);
1040
1637
  if (revoicedByScene.has(sceneIndex))
1041
1638
  lines.push(" voice: cast voice applied");
1639
+ if (voiceFirstByScene.has(sceneIndex))
1640
+ lines.push(" clip: redone voice first");
1641
+ const blockedBy = voiceFirstBlockedByScene.get(sceneIndex);
1642
+ if (blockedBy) {
1643
+ lines.push(` clip: redone the usual way — ${cantSayNames(blockedBy)}`);
1644
+ }
1042
1645
  if (timedOnOriginalVoice.has(sceneIndex)) {
1043
1646
  lines.push(" word timings: measured on the original voice (close, not frame-exact)");
1044
1647
  }
@@ -1057,6 +1660,45 @@ export async function statusFlow(runId, json, deps) {
1057
1660
  : "Final cut: not uploaded yet.");
1058
1661
  return { code: 0, lines };
1059
1662
  }
1663
+ export async function rejectedDraftFlow(runId, json, deps) {
1664
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1665
+ if (!runRes.ok)
1666
+ return errorResult(runRes, json);
1667
+ const node = failedStoryboardNode(asVideoRun(runRes.data));
1668
+ if (!node) {
1669
+ const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
1670
+ return {
1671
+ code: 0,
1672
+ lines: json ? [JSON.stringify({ runId, nodeId: null, draft: null })] : [note],
1673
+ };
1674
+ }
1675
+ const res = await deps.get(`${REJECTED_DRAFT_PATH}?runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(node.nodeId)}`);
1676
+ if (missingRouteLine(res, "exodus video status --rejected-draft")) {
1677
+ return {
1678
+ code: 1,
1679
+ lines: json
1680
+ ? [JSON.stringify({ ok: false, status: 404, error: REJECTED_DRAFT_NOT_ON_THIS_SERVER })]
1681
+ : [REJECTED_DRAFT_NOT_ON_THIS_SERVER],
1682
+ };
1683
+ }
1684
+ if (!res.ok)
1685
+ return errorResult(res, json);
1686
+ const draft = res.data.draft ?? null;
1687
+ if (json)
1688
+ return { code: 0, lines: [JSON.stringify({ runId, nodeId: node.nodeId, draft })] };
1689
+ if (!draft)
1690
+ return { code: 0, lines: ["No rejected draft was kept for this attempt."] };
1691
+ return {
1692
+ code: 0,
1693
+ lines: [
1694
+ `REJECTED DRAFT — ad run ${runId}, step ${node.nodeId}`,
1695
+ "The system turned this draft down and threw it away. It is kept only as a record of what happened.",
1696
+ "Nothing in it is part of your ad. There is nothing here to act on or edit.",
1697
+ "",
1698
+ ...draft.split("\n"),
1699
+ ],
1700
+ };
1701
+ }
1060
1702
  export async function storyboardFlow(runId, json, deps) {
1061
1703
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
1062
1704
  if (!res.ok)
@@ -1108,7 +1750,14 @@ export async function storyboardFlow(runId, json, deps) {
1108
1750
  }
1109
1751
  }
1110
1752
  }
1111
- lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
1753
+ const runRes = await deps
1754
+ .get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`)
1755
+ .catch(() => null);
1756
+ const showAd = runRes?.ok === true && isShowAd(asVideoRun(runRes.data));
1757
+ lines.push("", `approve with: exodus video approve ${runId}`);
1758
+ if (showAd) {
1759
+ lines.push(`send it back: exodus video flag ${runId} --note "what's wrong"`);
1760
+ }
1112
1761
  if (cards.framesNodeId) {
1113
1762
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
1114
1763
  }
@@ -1136,14 +1785,30 @@ function sceneCardLines(scene) {
1136
1785
  lines.push(` picture: ${scene.frame.status}`);
1137
1786
  return lines;
1138
1787
  }
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;
1788
+ export async function approveFlow(runId, opts, deps) {
1789
+ const res = await deps.post(APPROVE_PATH, {
1790
+ runId,
1791
+ ...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
1792
+ });
1793
+ if (!res.ok) {
1794
+ const refusal = errorResult(res, opts.json);
1795
+ const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
1796
+ return staleCut
1797
+ ? {
1798
+ ...refusal,
1799
+ lines: [
1800
+ ...refusal.lines,
1801
+ `To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
1802
+ ],
1803
+ }
1804
+ : refusal;
1805
+ }
1806
+ const data = res.data;
1807
+ const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
1808
+ if (opts.json) {
1809
+ return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
1810
+ }
1811
+ const voices = data?.voices;
1147
1812
  const summary = voices
1148
1813
  ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1149
1814
  : [];
@@ -1151,6 +1816,7 @@ export async function approveFlow(runId, json, deps) {
1151
1816
  code: 0,
1152
1817
  lines: [
1153
1818
  "Approved.",
1819
+ ...warnings,
1154
1820
  ...(summary.length > 0 ? ["", ...summary, ""] : []),
1155
1821
  `See what happens next: exodus video status ${runId}`,
1156
1822
  ],
@@ -1208,8 +1874,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1208
1874
  ],
1209
1875
  };
1210
1876
  }
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.";
1877
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
1878
+ "exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
1879
+ "or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
1213
1880
  export function planClipRedo(run, items, target) {
1214
1881
  const stop = classifyRun(run);
1215
1882
  const found = findClipRow(run, items, target);
@@ -1218,7 +1885,10 @@ export function planClipRedo(run, items, target) {
1218
1885
  const { row, uploadedCut } = found;
1219
1886
  const redoable = row.status === "failed" ||
1220
1887
  row.staleClaim === true ||
1221
- (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
1888
+ (row.status === "done" &&
1889
+ (row.flagged === true ||
1890
+ stop.at === "final-watch" ||
1891
+ (stop.at === "running" && stop.autoRedoScenes !== undefined)));
1222
1892
  if (!redoable) {
1223
1893
  if (row.status === "done") {
1224
1894
  return {
@@ -1275,7 +1945,7 @@ function findClipRow(run, items, target) {
1275
1945
  }
1276
1946
  return { row, uploadedCut };
1277
1947
  }
1278
- export async function retryClipFlow(runId, target, note, json, deps) {
1948
+ export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
1279
1949
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1280
1950
  if (!runRes.ok)
1281
1951
  return errorResult(runRes, json);
@@ -1291,12 +1961,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1291
1961
  lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1292
1962
  };
1293
1963
  }
1294
- const res = await deps.post(SCENE_RETRY_PATH, {
1964
+ const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
1295
1965
  runId,
1296
1966
  nodeId: plan.nodeId,
1297
1967
  sceneIndex: plan.sceneIndex,
1298
1968
  ...(note ? { note } : {}),
1299
1969
  });
1970
+ if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
1971
+ return {
1972
+ code: 1,
1973
+ lines: json
1974
+ ? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
1975
+ : [VOICE_FIRST_NOT_ON_THIS_SERVER],
1976
+ };
1977
+ }
1300
1978
  if (!res.ok)
1301
1979
  return errorResult(res, json);
1302
1980
  const triggerRunId = res.data.triggerRunId;
@@ -1315,6 +1993,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1315
1993
  reviewUrl: review,
1316
1994
  warnings: plan.warnings,
1317
1995
  ...(note ? { note } : {}),
1996
+ ...(voiceFirst ? { voiceMode: "voice-first" } : {}),
1318
1997
  }),
1319
1998
  ],
1320
1999
  };
@@ -1322,9 +2001,19 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1322
2001
  return {
1323
2002
  code: 0,
1324
2003
  lines: [
1325
- `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
2004
+ voiceFirst
2005
+ ? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
2006
+ "recorded in the character's own voice, then the clip is made to match it. " +
2007
+ "If the line has a made-up name the recorded voice can't say, the clip may be made " +
2008
+ `the usual way instead, and exodus video status ${runId} will say so.`
2009
+ : `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1326
2010
  `triggerRunId: ${triggerRunId ?? "-"}`,
1327
- "Every other scene stays as it is, and so do the pictures, the voices and the script.",
2011
+ ...(voiceFirst
2012
+ ? [
2013
+ "Every other scene stays as it is, and so do the pictures and the script.",
2014
+ "A speaking character with no voice yet gets one picked and saved for the whole run.",
2015
+ ]
2016
+ : ["Every other scene stays as it is, and so do the pictures, the voices and the script."]),
1328
2017
  ...(note
1329
2018
  ? ["Your note steers how this clip moves. It never changes the words that are spoken."]
1330
2019
  : []),
@@ -1641,6 +2330,13 @@ export function voiceSheetLines(sheet) {
1641
2330
  : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
1642
2331
  return lines;
1643
2332
  }
2333
+ function musicLines(manifest) {
2334
+ const lines = [`music: ${manifest.musicBed}`];
2335
+ if (manifest.musicHeardScenes.length > 0) {
2336
+ lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
2337
+ }
2338
+ return lines;
2339
+ }
1644
2340
  export async function pullFlow(runId, dir, json, deps) {
1645
2341
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1646
2342
  if (!runRes.ok)
@@ -1684,7 +2380,23 @@ export async function pullFlow(runId, dir, json, deps) {
1684
2380
  const lines = [
1685
2381
  `Pulled ${wrote} files into ${dir}`,
1686
2382
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2383
+ ...musicLines(plan.manifest),
1687
2384
  ];
2385
+ for (const sceneIndex of plan.manifest.pendingRedo) {
2386
+ lines.push("", `Scene ${sceneIndex} is being redone right now; its file will change. Run \`exodus video wait ${runId}\`, then pull again.`);
2387
+ }
2388
+ const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
2389
+ if (voiceFirst.length > 0) {
2390
+ lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
2391
+ `scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
2392
+ }
2393
+ const redoneUsualWay = plan.manifest.scenes.filter((s) => s.voiceFirstBlockedBy?.length);
2394
+ if (redoneUsualWay.length > 0) {
2395
+ lines.push("", "Asked for voice first, but redone the usual way:");
2396
+ for (const scene of redoneUsualWay) {
2397
+ lines.push(` scene ${scene.sceneIndex}: ${cantSayNames(scene.voiceFirstBlockedBy ?? [])}`);
2398
+ }
2399
+ }
1688
2400
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
1689
2401
  if (flagged.length > 0) {
1690
2402
  lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
@@ -1940,7 +2652,12 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
1940
2652
  const VALUE_FLAGS = new Set([
1941
2653
  "show",
1942
2654
  "script",
2655
+ "conceit",
2656
+ "style",
2657
+ "direction",
1943
2658
  "voice-path",
2659
+ "video-model",
2660
+ "voice",
1944
2661
  "note",
1945
2662
  "out",
1946
2663
  "file",
@@ -1993,20 +2710,12 @@ export async function run(flags, occurrences) {
1993
2710
  if (sub === "shows")
1994
2711
  return printResult(await showsFlow(json, defaultDeps));
1995
2712
  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));
2713
+ const plan = planVideoStart(flags, occurrences);
2714
+ if (plan.kind === "usage")
2715
+ usage(plan.line);
2716
+ if (plan.kind === "show")
2717
+ return printResult(await startFlow(plan.opts, defaultDeps));
2718
+ return printResult(await startScriptFlow(plan.opts, defaultDeps));
2010
2719
  }
2011
2720
  const runId = rest[0];
2012
2721
  const needsRunId = [
@@ -2024,12 +2733,17 @@ export async function run(flags, occurrences) {
2024
2733
  if (needsRunId.includes(sub) && !runId) {
2025
2734
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
2026
2735
  }
2027
- if (sub === "status")
2736
+ if (sub === "status") {
2737
+ if (flags["rejected-draft"] !== undefined && flags["rejected-draft"] !== false) {
2738
+ return printResult(await rejectedDraftFlow(runId, json, defaultDeps));
2739
+ }
2028
2740
  return printResult(await statusFlow(runId, json, defaultDeps));
2741
+ }
2029
2742
  if (sub === "storyboard")
2030
2743
  return printResult(await storyboardFlow(runId, json, defaultDeps));
2031
- if (sub === "approve")
2032
- return printResult(await approveFlow(runId, json, defaultDeps));
2744
+ if (sub === "approve") {
2745
+ return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2746
+ }
2033
2747
  if (sub === "flag") {
2034
2748
  const note = flagString(flags, "note");
2035
2749
  if (!note)
@@ -2059,7 +2773,7 @@ export async function run(flags, occurrences) {
2059
2773
  if (!Number.isInteger(sceneIndex)) {
2060
2774
  usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
2061
2775
  }
2062
- return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
2776
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
2063
2777
  }
2064
2778
  if (sub === "revoice") {
2065
2779
  const sceneRaw = flagString(flags, "scene");