@aicopycoders/exodus 2026.9.2000-beta.1 → 2026.9.2000-beta.11

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,27 @@ 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
+ }
9
24
  export const helpText = `
10
25
  exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
11
26
 
12
27
  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
28
+ one voice track per scene, one video clip per scene, and a music bed if you
29
+ asked for one with --music. It does
14
30
  not make the finished ad. You pull the pieces to a folder, cut them together
15
31
  with whatever editing tools you like, and upload the cut back.
16
32
 
@@ -41,14 +57,22 @@ The whole loop, in order:
41
57
  Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
42
58
 
43
59
  7. exodus video status <runId>
44
- Where the run is and how each scene's clip turned out.
60
+ Where the run is and how each scene's clip turned out. If the storyboard
61
+ failed, it also says which parts of it were accepted and why the next one
62
+ was turned down.
63
+
64
+ exodus video status <runId> --rejected-draft
65
+ Prints the storyboard draft that was turned down. A record of what
66
+ happened, thrown away by the system — never something to act on.
45
67
 
46
68
  8. exodus video pull <runId> --out ./ad
47
69
  Writes every piece to that folder plus a manifest.json index.
48
70
 
49
71
  exodus video retry-clip <runId> --scene <n>
50
72
  Redo ONE finished clip while the run waits for the cut. Everything
51
- else stays. A --note steers the motion, never the words.
73
+ else stays. A --note steers the motion, never the words. Add
74
+ --voice-first when the words came out wrong: the line is recorded in
75
+ the character's own voice first, then the clip is made to match it.
52
76
 
53
77
  exodus video revoice <runId> --all
54
78
  Redo only the VOICE on clips that kept the video model's own voice
@@ -62,18 +86,21 @@ The whole loop, in order:
62
86
  Attaches your cut to the run and prints the page to approve it on.
63
87
 
64
88
  11. exodus video approve <runId>
65
- Or click Approve on the page from step 10.
89
+ Or click Approve on the page from step 10. If you redid a clip after
90
+ uploading, this stops and says which scenes changed. Upload a fresh cut,
91
+ or add --approve-stale-cut to deliver the cut you already uploaded.
66
92
 
67
93
  Usage:
68
- exodus video status <runId> [--json]
94
+ exodus video status <runId> [--rejected-draft] [--json]
69
95
  exodus video storyboard <runId> [--json]
70
- exodus video approve <runId> [--json]
96
+ exodus video approve <runId> [--approve-stale-cut] [--json]
71
97
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
72
- exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
98
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
73
99
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
74
100
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
75
101
  exodus video pull <runId> --out <dir> [--json]
76
102
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
103
+ 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
104
 
78
105
  Options:
79
106
  --out <dir> Folder to write the pulled pieces into (pull)
@@ -88,12 +115,33 @@ Options:
88
115
  (retry-frame, retry-clip, revoice)
89
116
  --all Every finished clip that kept the video model's voice
90
117
  (revoice)
118
+ --voice-first Record the scene's line in the character's own voice,
119
+ then make the clip to match it (retry-clip). For a clip
120
+ whose words came out wrong
121
+ --approve-stale-cut Deliver the cut you uploaded even though a clip changed
122
+ after you uploaded it (approve). Without it, approving
123
+ stops and names the scenes that changed
91
124
  --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
92
125
  character by its ID or by the name your script uses.
93
126
  Repeat it once per character
94
127
  --clear <who> Take a character's voice off again (voices). Repeatable
95
128
  --from <file.json> A file of characters and voice IDs (voices). --set and
96
129
  --clear win over the same name in the file
130
+ --rejected-draft Print the storyboard draft the system turned down
131
+ (status). It was thrown away — a record only
132
+ --script <file> Text file of what the ad says (start)
133
+ --conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
134
+ --style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
135
+ --direction "<note>" Note for the whole ad (start)
136
+ --voice-path <path> How the voices are made (start)
137
+ --video-model <id> Which video model films this ad, instead of the kind of
138
+ ad's own default (start, with --conceit)
139
+ --voice <mode> native: the video model speaks the lines. voice-first:
140
+ the lines are recorded first (start, with --conceit)
141
+ --music Put a music bed under the ad (start). An ad has no music
142
+ unless you ask for it here
143
+ --no-music Leave the music bed off (start), which it already is
144
+ --wait Wait until the storyboard needs a yes (start)
97
145
  --json Machine-readable output
98
146
  --help, -h Print this help
99
147
 
@@ -108,6 +156,7 @@ Examples:
108
156
  exodus video approve run_123
109
157
  exodus video retry-frame run_123 --node frames-1 --scene 2
110
158
  exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
159
+ exodus video retry-clip run_123 --scene 3 --voice-first
111
160
  exodus video revoice run_123 --all
112
161
  exodus video voices run_123
113
162
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
@@ -117,13 +166,20 @@ Examples:
117
166
  `.trim();
118
167
  const SHOWS_PATH = "/api/v2/shows";
119
168
  const RUNS_PATH = "/api/v2/video/runs";
169
+ const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
120
170
  const RUN_PATH = "/api/v2/workflow";
121
171
  const ITEMS_PATH = "/api/v2/workflow/items";
172
+ const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
173
+ const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
174
+ "with the next server update.";
122
175
  const STORYBOARD_PATH = "/api/v2/video/storyboard";
123
176
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
124
177
  const APPROVE_PATH = "/api/v2/workflow/approve";
125
178
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
126
179
  const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
180
+ const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
181
+ const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
182
+ "was spent. It arrives with the next server update.";
127
183
  const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
128
184
  "was spent. It arrives with the next server update.";
129
185
  const FINAL_PATH = "/api/v2/video/final";
@@ -133,6 +189,48 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
133
189
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
134
190
  return rest;
135
191
  }
192
+ export function qcWithoutJudgeWording(qc) {
193
+ if (!qc.takes)
194
+ return qc;
195
+ return {
196
+ ...qc,
197
+ takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
198
+ };
199
+ }
200
+ export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
201
+ const TAKE_NEIGHBOUR_SENTENCE = {
202
+ "not-applicable": () => "never compared",
203
+ "not-attached": () => "no neighbours attached",
204
+ "none-accepted": () => "no accepted neighbours yet",
205
+ attached: ({ scenes }) => `compared against scene${scenes.length === 1 ? "" : "s"} ${scenes.join(", ")}`,
206
+ };
207
+ export function renderQcTakeHistory(takes) {
208
+ if (!takes || takes.length === 0)
209
+ return [NO_TAKE_HISTORY_LINE];
210
+ const lines = [];
211
+ takes.forEach((take, index) => {
212
+ const say = TAKE_NEIGHBOUR_SENTENCE[take.neighbours.state];
213
+ const parts = [
214
+ take.failCodes.length ? `failed ${take.failCodes.join(", ")}` : "passed",
215
+ ...(take.warnCodes.length ? [`warned ${take.warnCodes.join(", ")}`] : []),
216
+ say(take.neighbours),
217
+ ];
218
+ const bonus = take.kind === "soft-retry" ? " (bonus take)" : "";
219
+ lines.push(`take ${index + 1}${bonus}: ${parts.join("; ")}`);
220
+ for (const { code, wording } of take.judgeWording ?? []) {
221
+ lines.push(` checker said (${code}): ${wording}`);
222
+ }
223
+ });
224
+ return lines;
225
+ }
226
+ function printDisplacedHistory(lines, lastRedo) {
227
+ const history = lastRedo.displacedHistory;
228
+ if (!history)
229
+ return;
230
+ lines.push(` ${history.title}`);
231
+ for (const line of history.lines)
232
+ lines.push(` ${line}`);
233
+ }
136
234
  export function asVideoRun(data) {
137
235
  const run = (data ?? {});
138
236
  return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
@@ -245,6 +343,7 @@ export function classifyRun(run) {
245
343
  ...(pausedNode.kind === "scene-frames"
246
344
  ? { framesNodeId: pausedNode.nodeId }
247
345
  : {}),
346
+ ...(isShowAd(run) ? { showAd: true } : {}),
248
347
  };
249
348
  }
250
349
  if (parkedAtFinalWatch(run))
@@ -254,6 +353,22 @@ export function classifyRun(run) {
254
353
  const active = run.nodes.find((n) => n.status === "running");
255
354
  return { at: "running", stage: active?.kind ?? "starting" };
256
355
  }
356
+ export function hasAttachedCut(items) {
357
+ return items.some((i) => i.itemKind === "final" && i.status === "done");
358
+ }
359
+ export function resolveStop(stop, cutAttached) {
360
+ if (stop.at !== "final-watch")
361
+ return stop;
362
+ return { at: "final-watch", cutAttached };
363
+ }
364
+ export async function resolveStopAtPark(stop, runId, deps) {
365
+ if (stop.at !== "final-watch")
366
+ return stop;
367
+ const res = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
368
+ if (!res.ok)
369
+ return resolveStop(stop, null);
370
+ return resolveStop(stop, hasAttachedCut(res.data.items ?? []));
371
+ }
257
372
  const STAGE_WORDS = {
258
373
  brief: "reading the script",
259
374
  storyboard: "writing the storyboard",
@@ -279,8 +394,11 @@ export function stepName(kind) {
279
394
  return "A step in this run";
280
395
  return STEP_NAMES[kind] ?? `The ${kind} step`;
281
396
  }
397
+ export function isShowAd(run) {
398
+ return run.moduleOwned === true;
399
+ }
282
400
  export function reviewUrl(dashboardUrl, run) {
283
- if (run.moduleOwned === true)
401
+ if (isShowAd(run))
284
402
  return `${dashboardUrl}/video?ad=${run._id}`;
285
403
  if (run.workflowId)
286
404
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
@@ -292,14 +410,31 @@ export function stopLines(stop, runId, runUrl) {
292
410
  "Parked: the storyboard is waiting for your yes.",
293
411
  `Read it: exodus video storyboard ${runId}`,
294
412
  `Approve it: exodus video approve ${runId}`,
295
- `Send back: exodus video flag ${runId} --note "what's wrong"`,
296
413
  ];
414
+ if (stop.showAd) {
415
+ lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
416
+ }
297
417
  if (stop.framesNodeId) {
298
418
  lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
299
419
  }
300
420
  return lines;
301
421
  }
302
422
  if (stop.at === "final-watch") {
423
+ if (stop.cutAttached === null) {
424
+ return [
425
+ "Parked: every piece is made.",
426
+ `Couldn't check whether a cut is already uploaded. See where it stands: exodus video status ${runId}`,
427
+ `Watch it here: ${runUrl}`,
428
+ ];
429
+ }
430
+ if (stop.cutAttached) {
431
+ return [
432
+ "Parked: your cut is uploaded and waiting for your approval.",
433
+ `Approve it: exodus video approve ${runId}`,
434
+ `Watch it here: ${runUrl}`,
435
+ "Changed your mind? You can still redo a clip and upload a new cut.",
436
+ ];
437
+ }
303
438
  return [
304
439
  "Parked: every piece is made and the run is waiting for a cut.",
305
440
  `Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
@@ -310,6 +445,13 @@ export function stopLines(stop, runId, runUrl) {
310
445
  }
311
446
  if (stop.at === "failed") {
312
447
  if (stop.repair) {
448
+ if (!stop.step) {
449
+ return [
450
+ "This run stopped at a step that can't be picked back up from here.",
451
+ "Start a fresh run to get this ad made.",
452
+ `Open the run: ${runUrl}`,
453
+ ];
454
+ }
313
455
  return [
314
456
  `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
315
457
  `Try that step again: exodus workflow repair ${runId} retry`,
@@ -335,7 +477,40 @@ export function stopLines(stop, runId, runUrl) {
335
477
  }
336
478
  return [`Working: ${stageWord(stop.stage)}.`];
337
479
  }
338
- export const CAST_LEDGER_BASE = 910000;
480
+ export function failedStoryboardNode(run) {
481
+ return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
482
+ }
483
+ export function errorEchoesReasons(error, record) {
484
+ if (!error || record?.kind !== "rejected")
485
+ return false;
486
+ const reasons = record.reasons ?? [];
487
+ return reasons.length > 0 && reasons.every((reason) => error.includes(reason));
488
+ }
489
+ export function planFailureLines(node, runId) {
490
+ const record = node?.planFailure;
491
+ if (!record || !Array.isArray(record.lines) || !node)
492
+ return [];
493
+ const reasons = record.kind === "rejected" ? (record.reasons ?? []) : [];
494
+ const bodyCount = Math.max(0, record.lines.length - reasons.length);
495
+ const lines = [`What happened to the storyboard step (${node.nodeId}):`];
496
+ record.lines.forEach((line, i) => {
497
+ lines.push(i < bodyCount ? ` ${line}` : ` - ${line}`);
498
+ });
499
+ if (node?.hasRejectedDraft === true) {
500
+ 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`);
501
+ }
502
+ return lines;
503
+ }
504
+ export const MUSIC_HEARD_CODE = "music-heard";
505
+ export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
506
+ export function musicBedState(run) {
507
+ if (run.musicBed === undefined)
508
+ return "unknown";
509
+ return run.musicBed ? "on" : "off";
510
+ }
511
+ function isPullSceneIndex(sceneIndex) {
512
+ return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
513
+ }
339
514
  const FALLBACK_EXT = {
340
515
  image: "png",
341
516
  video: "mp4",
@@ -352,6 +527,10 @@ function extFor(url, family) {
352
527
  export function scenePrefix(sceneIndex) {
353
528
  return `scene-${String(sceneIndex).padStart(2, "0")}`;
354
529
  }
530
+ function clipQcOf(item) {
531
+ const artifact = item?.artifact;
532
+ return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
533
+ }
355
534
  function outputsOfNodeKind(run, kind) {
356
535
  return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
357
536
  }
@@ -370,6 +549,7 @@ function clipFromArtifact(sceneIndex, artifact) {
370
549
  revoiced: artifact.revoiced === true,
371
550
  speechTrimmed: artifact.speechTrimmed === true,
372
551
  rawStorageId: artifact.rawStorageId ?? null,
552
+ voiceMode: artifact.voiceMode ?? null,
373
553
  };
374
554
  }
375
555
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -381,7 +561,9 @@ function keyframeDownload(sceneIndex, imageUrl) {
381
561
  function reservedCastFrames(items) {
382
562
  const byIndex = new Map();
383
563
  for (const item of items) {
384
- if (item.itemKind === "frame" && item.sceneIndex >= CAST_LEDGER_BASE) {
564
+ if (item.itemKind === "frame" &&
565
+ item.sceneIndex >= CAST_LEDGER_BASE &&
566
+ item.sceneIndex < SET_OPTION_LEDGER_BASE) {
385
567
  byIndex.set(item.sceneIndex, item);
386
568
  }
387
569
  }
@@ -534,6 +716,8 @@ export function planPull(run, items, opts) {
534
716
  for (const frame of artifact.frames ?? []) {
535
717
  if (!frame.imageUrl)
536
718
  continue;
719
+ if (!isPullSceneIndex(frame.sceneIndex))
720
+ continue;
537
721
  keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
538
722
  }
539
723
  }
@@ -551,7 +735,7 @@ export function planPull(run, items, opts) {
551
735
  };
552
736
  continue;
553
737
  }
554
- if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex < 0)
738
+ if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
555
739
  continue;
556
740
  voiceByScene.set(artifact.sceneIndex, {
557
741
  download: {
@@ -592,6 +776,8 @@ export function planPull(run, items, opts) {
592
776
  }
593
777
  if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
594
778
  continue;
779
+ if (!isPullSceneIndex(artifact.sceneIndex))
780
+ continue;
595
781
  const clip = clipFromArtifact(artifact.sceneIndex, artifact);
596
782
  if (!clip)
597
783
  continue;
@@ -601,6 +787,8 @@ export function planPull(run, items, opts) {
601
787
  const frameItemByScene = new Map();
602
788
  const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
603
789
  for (const item of items) {
790
+ if (!isPullSceneIndex(item.sceneIndex))
791
+ continue;
604
792
  if (item.itemKind === "clip")
605
793
  clipItemByScene.set(item.sceneIndex, item);
606
794
  if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
@@ -632,7 +820,7 @@ export function planPull(run, items, opts) {
632
820
  ...frameItemByScene.keys(),
633
821
  ]),
634
822
  ]
635
- .filter((sceneIndex) => sceneIndex >= 0 && sceneIndex < CAST_LEDGER_BASE)
823
+ .filter((sceneIndex) => isPullSceneIndex(sceneIndex))
636
824
  .sort((a, b) => a - b);
637
825
  const scenes = sceneIndexes.map((sceneIndex) => {
638
826
  const clip = clipByScene.get(sceneIndex);
@@ -667,10 +855,11 @@ export function planPull(run, items, opts) {
667
855
  : "this-file",
668
856
  voice: voice?.download.file ?? null,
669
857
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
670
- qc: clip?.qc ?? null,
858
+ qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
671
859
  revoiced: clip ? clip.revoiced : null,
672
860
  speechTrimmed: clip ? clip.speechTrimmed : null,
673
861
  rawStorageId: clip ? clip.rawStorageId : null,
862
+ voiceMode: clip ? clip.voiceMode : null,
674
863
  clipStatus: item?.status ?? "missing",
675
864
  error: item?.error ?? null,
676
865
  flagged: item?.flagged === true,
@@ -698,6 +887,10 @@ export function planPull(run, items, opts) {
698
887
  storyboard,
699
888
  reference: reference?.file ?? null,
700
889
  music: music?.file ?? null,
890
+ musicBed: musicBedState(run),
891
+ musicHeardScenes: scenes
892
+ .filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
893
+ .map((scene) => scene.sceneIndex),
701
894
  cast,
702
895
  narration: narrationDownload
703
896
  ? { file: narrationDownload.file, timing: "narration.json" }
@@ -707,6 +900,14 @@ export function planPull(run, items, opts) {
707
900
  ...(run.provenance?.format || run.provenance?.voice
708
901
  ? { provenance: run.provenance }
709
902
  : {}),
903
+ ...(run.videoChoice
904
+ ? {
905
+ videoChoice: {
906
+ videoModel: run.videoChoice.videoModel,
907
+ voiceMode: run.videoChoice.voiceMode,
908
+ },
909
+ }
910
+ : {}),
710
911
  },
711
912
  };
712
913
  }
@@ -732,6 +933,7 @@ export function markPullFailure(manifest, failure) {
732
933
  scene.revoiced = null;
733
934
  scene.speechTrimmed = null;
734
935
  scene.rawStorageId = null;
936
+ scene.voiceMode = null;
735
937
  }
736
938
  if (scene.voice === failure.file)
737
939
  scene.voice = null;
@@ -837,7 +1039,7 @@ export async function startFlow(opts, deps) {
837
1039
  showId: opts.showId,
838
1040
  script,
839
1041
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
840
- ...(opts.music === false ? { music: false } : {}),
1042
+ ...(opts.music === undefined ? {} : { music: opts.music }),
841
1043
  });
842
1044
  if (!res.ok)
843
1045
  return errorResult(res, opts.json);
@@ -866,6 +1068,264 @@ export async function startFlow(opts, deps) {
866
1068
  lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
867
1069
  };
868
1070
  }
1071
+ function nonBlank(value) {
1072
+ if (value === undefined || value.trim().length === 0)
1073
+ return undefined;
1074
+ return value;
1075
+ }
1076
+ function isVoiceMode(value) {
1077
+ return VOICE_MODES.some((mode) => mode === value);
1078
+ }
1079
+ function isConceit(value) {
1080
+ return CONCEIT_KEYS.some((key) => key === value);
1081
+ }
1082
+ export function planMusicChoice(occurrences) {
1083
+ let music;
1084
+ for (const { flag, value } of occurrences) {
1085
+ if (flag === "no-music") {
1086
+ music = false;
1087
+ continue;
1088
+ }
1089
+ if (flag !== "music")
1090
+ continue;
1091
+ if (value === undefined || value.startsWith("--")) {
1092
+ music = true;
1093
+ continue;
1094
+ }
1095
+ const word = value.trim().toLowerCase();
1096
+ const literal = word === "true" ? true : word === "false" ? false : undefined;
1097
+ if (literal === undefined) {
1098
+ return {
1099
+ ok: false,
1100
+ line: `video start --music takes true or false, or no value at all (got "${value}").`,
1101
+ };
1102
+ }
1103
+ music = literal;
1104
+ }
1105
+ return { ok: true, music };
1106
+ }
1107
+ function planValueFlag(occurrences, name) {
1108
+ let value;
1109
+ for (const occurrence of occurrences) {
1110
+ if (occurrence.flag !== name)
1111
+ continue;
1112
+ const given = occurrence.value;
1113
+ if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
1114
+ return { ok: false, line: `video start --${name} needs a value.` };
1115
+ }
1116
+ value = given.trim();
1117
+ }
1118
+ return { ok: true, value };
1119
+ }
1120
+ export function planVideoStart(flags, occurrences) {
1121
+ const showId = nonBlank(flagString(flags, "show"));
1122
+ const scriptFile = nonBlank(flagString(flags, "script"));
1123
+ const conceit = nonBlank(flagString(flags, "conceit"));
1124
+ const style = nonBlank(flagString(flags, "style"));
1125
+ if (showId && (conceit || style)) {
1126
+ return {
1127
+ kind: "usage",
1128
+ line: "Pass either --show <id>, or --conceit and --style. Not both.",
1129
+ };
1130
+ }
1131
+ if (!scriptFile) {
1132
+ return {
1133
+ kind: "usage",
1134
+ line: "video start needs --script <file>, a text file of what the ad says.",
1135
+ };
1136
+ }
1137
+ const musicChoice = planMusicChoice(occurrences);
1138
+ if (!musicChoice.ok)
1139
+ return { kind: "usage", line: musicChoice.line };
1140
+ const videoModelFlag = planValueFlag(occurrences, "video-model");
1141
+ if (!videoModelFlag.ok)
1142
+ return { kind: "usage", line: videoModelFlag.line };
1143
+ const voiceFlag = planValueFlag(occurrences, "voice");
1144
+ if (!voiceFlag.ok)
1145
+ return { kind: "usage", line: voiceFlag.line };
1146
+ const videoModel = videoModelFlag.value;
1147
+ const voiceMode = voiceFlag.value;
1148
+ if (showId && (videoModel || voiceMode)) {
1149
+ return {
1150
+ kind: "usage",
1151
+ line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
1152
+ };
1153
+ }
1154
+ if (voiceMode && !isVoiceMode(voiceMode)) {
1155
+ return {
1156
+ kind: "usage",
1157
+ line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
1158
+ };
1159
+ }
1160
+ if (showId) {
1161
+ return {
1162
+ kind: "show",
1163
+ opts: {
1164
+ showId,
1165
+ scriptFile,
1166
+ voicePath: flagString(flags, "voice-path"),
1167
+ music: musicChoice.music,
1168
+ wait: flags["wait"] === true,
1169
+ json: flags["json"] === true,
1170
+ },
1171
+ };
1172
+ }
1173
+ if (!conceit) {
1174
+ return {
1175
+ kind: "usage",
1176
+ line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
1177
+ };
1178
+ }
1179
+ if (!isConceit(conceit)) {
1180
+ return {
1181
+ kind: "usage",
1182
+ line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
1183
+ };
1184
+ }
1185
+ if (!style) {
1186
+ return {
1187
+ kind: "usage",
1188
+ line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
1189
+ };
1190
+ }
1191
+ const direction = nonBlank(flagString(flags, "direction"));
1192
+ const voicePath = nonBlank(flagString(flags, "voice-path"));
1193
+ const opts = {
1194
+ scriptFile,
1195
+ conceit,
1196
+ style,
1197
+ wait: flags["wait"] === true,
1198
+ json: flags["json"] === true,
1199
+ };
1200
+ if (direction)
1201
+ opts.direction = direction;
1202
+ if (voicePath)
1203
+ opts.voicePath = voicePath;
1204
+ if (videoModel)
1205
+ opts.videoModel = videoModel;
1206
+ if (voiceMode && isVoiceMode(voiceMode))
1207
+ opts.voiceMode = voiceMode;
1208
+ if (musicChoice.music !== undefined)
1209
+ opts.music = musicChoice.music;
1210
+ return { kind: "script", opts };
1211
+ }
1212
+ function isRecord(value) {
1213
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1214
+ }
1215
+ function isResolvedCard(value) {
1216
+ if (!isRecord(value))
1217
+ return false;
1218
+ if (!isRecord(value.conceit) || !isRecord(value.style))
1219
+ return false;
1220
+ if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
1221
+ return false;
1222
+ if (typeof value.family !== "string")
1223
+ return false;
1224
+ if (!("wrapper" in value))
1225
+ return false;
1226
+ if (value.wrapper !== null && typeof value.wrapper !== "string")
1227
+ return false;
1228
+ if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
1229
+ return false;
1230
+ return true;
1231
+ }
1232
+ function startedVideoChoice(value) {
1233
+ if (!isRecord(value))
1234
+ return null;
1235
+ if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
1236
+ return null;
1237
+ return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1238
+ }
1239
+ function scriptStartedLines(runId, url, card, video) {
1240
+ return [
1241
+ `Started ad run ${runId}`,
1242
+ ...(card
1243
+ ? [
1244
+ `Conceit: ${card.conceit.label}`,
1245
+ `Family: ${card.family}`,
1246
+ `Wrapper: ${card.wrapper ?? ""}`,
1247
+ `Style: ${card.style.label}`,
1248
+ ]
1249
+ : ["The server did not include a resolved card."]),
1250
+ ...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
1251
+ `Watch it: ${url}`,
1252
+ ];
1253
+ }
1254
+ function waitJsonWithCard(waited, card, video) {
1255
+ const first = waited.lines[0];
1256
+ if (typeof first !== "string")
1257
+ return waited;
1258
+ let parsed;
1259
+ try {
1260
+ parsed = JSON.parse(first);
1261
+ }
1262
+ catch {
1263
+ return waited;
1264
+ }
1265
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1266
+ return waited;
1267
+ return {
1268
+ code: waited.code,
1269
+ lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1270
+ };
1271
+ }
1272
+ export async function startScriptFlow(opts, deps) {
1273
+ let script;
1274
+ try {
1275
+ script = deps.readFile(opts.scriptFile);
1276
+ }
1277
+ catch (e) {
1278
+ return {
1279
+ code: 1,
1280
+ lines: [
1281
+ `Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
1282
+ ],
1283
+ };
1284
+ }
1285
+ if (script.trim().length === 0) {
1286
+ return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
1287
+ }
1288
+ const res = await deps.post(SCRIPT_RUNS_PATH, {
1289
+ script,
1290
+ conceit: opts.conceit,
1291
+ style: opts.style,
1292
+ ...(opts.direction ? { direction: opts.direction } : {}),
1293
+ ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1294
+ ...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
1295
+ ...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
1296
+ ...(opts.music === true ? { music: true } : {}),
1297
+ });
1298
+ if (!res.ok)
1299
+ return errorResult(res, opts.json);
1300
+ const started = res.data;
1301
+ if (!started.runId) {
1302
+ return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
1303
+ }
1304
+ const runId = started.runId;
1305
+ const url = typeof started.url === "string" ? started.url : "";
1306
+ const card = isResolvedCard(started.card) ? started.card : null;
1307
+ const video = startedVideoChoice(started.videoChoice);
1308
+ if (!opts.wait) {
1309
+ return {
1310
+ code: 0,
1311
+ lines: opts.json
1312
+ ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1313
+ : [
1314
+ ...scriptStartedLines(runId, url, card, video),
1315
+ "",
1316
+ "Wait for the storyboard here instead: exodus video start … --wait",
1317
+ `Or check in whenever: exodus video status ${runId}`,
1318
+ ],
1319
+ };
1320
+ }
1321
+ const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1322
+ if (opts.json)
1323
+ return waitJsonWithCard(waited, card, video);
1324
+ return {
1325
+ code: waited.code,
1326
+ lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
1327
+ };
1328
+ }
869
1329
  const POLL_INTERVAL_MS = 5000;
870
1330
  const MAX_POLLS = 720;
871
1331
  export async function waitFlow(runId, opts, deps) {
@@ -891,15 +1351,16 @@ export async function waitFlow(runId, opts, deps) {
891
1351
  }
892
1352
  continue;
893
1353
  }
1354
+ const resolved = await resolveStopAtPark(stop, runId, deps);
894
1355
  if (opts.json) {
895
1356
  return {
896
1357
  code: stop.at === "failed" ? 1 : 0,
897
- lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
1358
+ lines: [JSON.stringify({ runId, stop: resolved, status: run.status, url: opts.url })],
898
1359
  };
899
1360
  }
900
1361
  return {
901
1362
  code: stop.at === "failed" ? 1 : 0,
902
- lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
1363
+ lines: [...lines, ...stopLines(resolved, runId, reviewUrl(deps.dashboardUrl, run))],
903
1364
  };
904
1365
  }
905
1366
  const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
@@ -924,6 +1385,11 @@ function itemWord(item) {
924
1385
  return "flagged";
925
1386
  return ITEM_STATUS_WORD[item.status] ?? item.status;
926
1387
  }
1388
+ 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.";
1389
+ function carryingOn(run) {
1390
+ return (run.status === "queued" &&
1391
+ run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
1392
+ }
927
1393
  export async function statusFlow(runId, json, deps) {
928
1394
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
929
1395
  if (!runRes.ok)
@@ -933,20 +1399,46 @@ export async function statusFlow(runId, json, deps) {
933
1399
  if (!itemsRes.ok)
934
1400
  return errorResult(itemsRes, json);
935
1401
  const items = itemsRes.data.items ?? [];
936
- const stop = classifyRun(run);
937
- const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
1402
+ const hasFinal = hasAttachedCut(items);
1403
+ const stop = resolveStop(classifyRun(run), hasFinal);
1404
+ const failedStoryboard = failedStoryboardNode(run);
1405
+ const guidance = stopLines(stop.at === "failed" && errorEchoesReasons(stop.error, failedStoryboard?.planFailure)
1406
+ ? { ...stop, error: undefined }
1407
+ : stop, runId, reviewUrl(deps.dashboardUrl, run));
938
1408
  const warnings = run.nodes
939
1409
  .filter((n) => n.warning)
940
1410
  .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
941
1411
  if (json) {
942
1412
  return {
943
1413
  code: 0,
944
- lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
1414
+ lines: [
1415
+ JSON.stringify({
1416
+ runId,
1417
+ status: run.status,
1418
+ stop,
1419
+ items,
1420
+ hasFinal,
1421
+ warnings,
1422
+ guidance,
1423
+ ...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
1424
+ ...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
1425
+ ...(failedStoryboard?.planFailure
1426
+ ? { storyboardNodeId: failedStoryboard.nodeId }
1427
+ : {}),
1428
+ ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1429
+ }),
1430
+ ],
945
1431
  };
946
1432
  }
947
1433
  const revoicedByScene = new Set();
1434
+ const voiceFirstByScene = new Set();
948
1435
  const timedOnOriginalVoice = new Set();
949
1436
  for (const artifact of outputsOfNodeKind(run, "video")) {
1437
+ if (artifact.type === "video" &&
1438
+ artifact.voiceMode === "voice-first" &&
1439
+ typeof artifact.sceneIndex === "number") {
1440
+ voiceFirstByScene.add(artifact.sceneIndex);
1441
+ }
950
1442
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
951
1443
  revoicedByScene.add(artifact.sceneIndex);
952
1444
  if ((artifact.words?.length ?? 0) > 0)
@@ -972,8 +1464,10 @@ export async function statusFlow(runId, json, deps) {
972
1464
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
973
1465
  const lines = [
974
1466
  headline,
975
- ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
1467
+ ...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
1468
+ ...guidance,
976
1469
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1470
+ ...planFailureLines(failedStoryboard, runId),
977
1471
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
978
1472
  ];
979
1473
  if (byScene.size === 0) {
@@ -984,6 +1478,21 @@ export async function statusFlow(runId, json, deps) {
984
1478
  for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
985
1479
  const row = byScene.get(sceneIndex);
986
1480
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1481
+ if (row.clip?.lastRedo) {
1482
+ lines.push(` clip: ${row.clip.lastRedo.label}`);
1483
+ printDisplacedHistory(lines, row.clip.lastRedo);
1484
+ }
1485
+ if (row.clip?.lastRedo || row.clip?.flagged) {
1486
+ for (const line of renderQcTakeHistory(clipQcOf(row.clip))) {
1487
+ lines.push(` ${line}`);
1488
+ }
1489
+ }
1490
+ if (row.voiceover?.lastRedo) {
1491
+ lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1492
+ printDisplacedHistory(lines, row.voiceover.lastRedo);
1493
+ }
1494
+ if (row.frame?.lastRedo)
1495
+ lines.push(` picture: ${row.frame.lastRedo.label}`);
987
1496
  for (const finding of row.clip?.findings ?? []) {
988
1497
  lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
989
1498
  const judgeSeverity = finding.judgeSeverity ?? finding.severity;
@@ -998,6 +1507,8 @@ export async function statusFlow(runId, json, deps) {
998
1507
  lines.push(` ${row.clip.error}`);
999
1508
  if (revoicedByScene.has(sceneIndex))
1000
1509
  lines.push(" voice: cast voice applied");
1510
+ if (voiceFirstByScene.has(sceneIndex))
1511
+ lines.push(" clip: redone voice first");
1001
1512
  if (timedOnOriginalVoice.has(sceneIndex)) {
1002
1513
  lines.push(" word timings: measured on the original voice (close, not frame-exact)");
1003
1514
  }
@@ -1016,6 +1527,45 @@ export async function statusFlow(runId, json, deps) {
1016
1527
  : "Final cut: not uploaded yet.");
1017
1528
  return { code: 0, lines };
1018
1529
  }
1530
+ export async function rejectedDraftFlow(runId, json, deps) {
1531
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1532
+ if (!runRes.ok)
1533
+ return errorResult(runRes, json);
1534
+ const node = failedStoryboardNode(asVideoRun(runRes.data));
1535
+ if (!node) {
1536
+ const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
1537
+ return {
1538
+ code: 0,
1539
+ lines: json ? [JSON.stringify({ runId, nodeId: null, draft: null })] : [note],
1540
+ };
1541
+ }
1542
+ const res = await deps.get(`${REJECTED_DRAFT_PATH}?runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(node.nodeId)}`);
1543
+ if (missingRouteLine(res, "exodus video status --rejected-draft")) {
1544
+ return {
1545
+ code: 1,
1546
+ lines: json
1547
+ ? [JSON.stringify({ ok: false, status: 404, error: REJECTED_DRAFT_NOT_ON_THIS_SERVER })]
1548
+ : [REJECTED_DRAFT_NOT_ON_THIS_SERVER],
1549
+ };
1550
+ }
1551
+ if (!res.ok)
1552
+ return errorResult(res, json);
1553
+ const draft = res.data.draft ?? null;
1554
+ if (json)
1555
+ return { code: 0, lines: [JSON.stringify({ runId, nodeId: node.nodeId, draft })] };
1556
+ if (!draft)
1557
+ return { code: 0, lines: ["No rejected draft was kept for this attempt."] };
1558
+ return {
1559
+ code: 0,
1560
+ lines: [
1561
+ `REJECTED DRAFT — ad run ${runId}, step ${node.nodeId}`,
1562
+ "The system turned this draft down and threw it away. It is kept only as a record of what happened.",
1563
+ "Nothing in it is part of your ad. There is nothing here to act on or edit.",
1564
+ "",
1565
+ ...draft.split("\n"),
1566
+ ],
1567
+ };
1568
+ }
1019
1569
  export async function storyboardFlow(runId, json, deps) {
1020
1570
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
1021
1571
  if (!res.ok)
@@ -1067,7 +1617,14 @@ export async function storyboardFlow(runId, json, deps) {
1067
1617
  }
1068
1618
  }
1069
1619
  }
1070
- lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
1620
+ const runRes = await deps
1621
+ .get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`)
1622
+ .catch(() => null);
1623
+ const showAd = runRes?.ok === true && isShowAd(asVideoRun(runRes.data));
1624
+ lines.push("", `approve with: exodus video approve ${runId}`);
1625
+ if (showAd) {
1626
+ lines.push(`send it back: exodus video flag ${runId} --note "what's wrong"`);
1627
+ }
1071
1628
  if (cards.framesNodeId) {
1072
1629
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
1073
1630
  }
@@ -1095,14 +1652,30 @@ function sceneCardLines(scene) {
1095
1652
  lines.push(` picture: ${scene.frame.status}`);
1096
1653
  return lines;
1097
1654
  }
1098
- export async function approveFlow(runId, json, deps) {
1099
- const res = await deps.post(APPROVE_PATH, { runId });
1100
- if (!res.ok)
1101
- return errorResult(res, json);
1102
- if (json)
1103
- return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
1104
- const voices = res.data
1105
- ?.voices;
1655
+ export async function approveFlow(runId, opts, deps) {
1656
+ const res = await deps.post(APPROVE_PATH, {
1657
+ runId,
1658
+ ...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
1659
+ });
1660
+ if (!res.ok) {
1661
+ const refusal = errorResult(res, opts.json);
1662
+ const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
1663
+ return staleCut
1664
+ ? {
1665
+ ...refusal,
1666
+ lines: [
1667
+ ...refusal.lines,
1668
+ `To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
1669
+ ],
1670
+ }
1671
+ : refusal;
1672
+ }
1673
+ const data = res.data;
1674
+ const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
1675
+ if (opts.json) {
1676
+ return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
1677
+ }
1678
+ const voices = data?.voices;
1106
1679
  const summary = voices
1107
1680
  ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1108
1681
  : [];
@@ -1110,6 +1683,7 @@ export async function approveFlow(runId, json, deps) {
1110
1683
  code: 0,
1111
1684
  lines: [
1112
1685
  "Approved.",
1686
+ ...warnings,
1113
1687
  ...(summary.length > 0 ? ["", ...summary, ""] : []),
1114
1688
  `See what happens next: exodus video status ${runId}`,
1115
1689
  ],
@@ -1167,8 +1741,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1167
1741
  ],
1168
1742
  };
1169
1743
  }
1170
- const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
1171
- "re-cut, and upload again.";
1744
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
1745
+ "exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
1746
+ "or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
1172
1747
  export function planClipRedo(run, items, target) {
1173
1748
  const stop = classifyRun(run);
1174
1749
  const found = findClipRow(run, items, target);
@@ -1201,7 +1776,7 @@ export function planClipRedo(run, items, target) {
1201
1776
  };
1202
1777
  }
1203
1778
  function findClipRow(run, items, target) {
1204
- const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
1779
+ const uploadedCut = hasAttachedCut(items);
1205
1780
  const rows = items.filter((i) => i.itemKind === "clip" &&
1206
1781
  i.sceneIndex === target.sceneIndex &&
1207
1782
  (target.nodeId === undefined || i.nodeId === target.nodeId));
@@ -1234,7 +1809,7 @@ function findClipRow(run, items, target) {
1234
1809
  }
1235
1810
  return { row, uploadedCut };
1236
1811
  }
1237
- export async function retryClipFlow(runId, target, note, json, deps) {
1812
+ export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
1238
1813
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1239
1814
  if (!runRes.ok)
1240
1815
  return errorResult(runRes, json);
@@ -1250,12 +1825,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1250
1825
  lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1251
1826
  };
1252
1827
  }
1253
- const res = await deps.post(SCENE_RETRY_PATH, {
1828
+ const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
1254
1829
  runId,
1255
1830
  nodeId: plan.nodeId,
1256
1831
  sceneIndex: plan.sceneIndex,
1257
1832
  ...(note ? { note } : {}),
1258
1833
  });
1834
+ if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
1835
+ return {
1836
+ code: 1,
1837
+ lines: json
1838
+ ? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
1839
+ : [VOICE_FIRST_NOT_ON_THIS_SERVER],
1840
+ };
1841
+ }
1259
1842
  if (!res.ok)
1260
1843
  return errorResult(res, json);
1261
1844
  const triggerRunId = res.data.triggerRunId;
@@ -1274,6 +1857,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1274
1857
  reviewUrl: review,
1275
1858
  warnings: plan.warnings,
1276
1859
  ...(note ? { note } : {}),
1860
+ ...(voiceFirst ? { voiceMode: "voice-first" } : {}),
1277
1861
  }),
1278
1862
  ],
1279
1863
  };
@@ -1281,7 +1865,10 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1281
1865
  return {
1282
1866
  code: 0,
1283
1867
  lines: [
1284
- `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1868
+ voiceFirst
1869
+ ? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
1870
+ "recorded in the character's own voice, then the clip is made to match it."
1871
+ : `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1285
1872
  `triggerRunId: ${triggerRunId ?? "-"}`,
1286
1873
  "Every other scene stays as it is, and so do the pictures, the voices and the script.",
1287
1874
  ...(note
@@ -1600,6 +2187,13 @@ export function voiceSheetLines(sheet) {
1600
2187
  : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
1601
2188
  return lines;
1602
2189
  }
2190
+ function musicLines(manifest) {
2191
+ const lines = [`music: ${manifest.musicBed}`];
2192
+ if (manifest.musicHeardScenes.length > 0) {
2193
+ lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
2194
+ }
2195
+ return lines;
2196
+ }
1603
2197
  export async function pullFlow(runId, dir, json, deps) {
1604
2198
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1605
2199
  if (!runRes.ok)
@@ -1643,7 +2237,13 @@ export async function pullFlow(runId, dir, json, deps) {
1643
2237
  const lines = [
1644
2238
  `Pulled ${wrote} files into ${dir}`,
1645
2239
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2240
+ ...musicLines(plan.manifest),
1646
2241
  ];
2242
+ const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
2243
+ if (voiceFirst.length > 0) {
2244
+ lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
2245
+ `scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
2246
+ }
1647
2247
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
1648
2248
  if (flagged.length > 0) {
1649
2249
  lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
@@ -1899,7 +2499,12 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
1899
2499
  const VALUE_FLAGS = new Set([
1900
2500
  "show",
1901
2501
  "script",
2502
+ "conceit",
2503
+ "style",
2504
+ "direction",
1902
2505
  "voice-path",
2506
+ "video-model",
2507
+ "voice",
1903
2508
  "note",
1904
2509
  "out",
1905
2510
  "file",
@@ -1952,20 +2557,12 @@ export async function run(flags, occurrences) {
1952
2557
  if (sub === "shows")
1953
2558
  return printResult(await showsFlow(json, defaultDeps));
1954
2559
  if (sub === "start") {
1955
- const showId = flagString(flags, "show");
1956
- const scriptFile = flagString(flags, "script");
1957
- if (!showId)
1958
- usage("video start needs --show <id>. List them with: exodus video shows");
1959
- if (!scriptFile)
1960
- usage("video start needs --script <file>, a text file of what the ad says.");
1961
- return printResult(await startFlow({
1962
- showId,
1963
- scriptFile,
1964
- voicePath: flagString(flags, "voice-path"),
1965
- music: flags["music"] === false ? false : undefined,
1966
- wait: flags["wait"] === true,
1967
- json,
1968
- }, defaultDeps));
2560
+ const plan = planVideoStart(flags, occurrences);
2561
+ if (plan.kind === "usage")
2562
+ usage(plan.line);
2563
+ if (plan.kind === "show")
2564
+ return printResult(await startFlow(plan.opts, defaultDeps));
2565
+ return printResult(await startScriptFlow(plan.opts, defaultDeps));
1969
2566
  }
1970
2567
  const runId = rest[0];
1971
2568
  const needsRunId = [
@@ -1983,12 +2580,17 @@ export async function run(flags, occurrences) {
1983
2580
  if (needsRunId.includes(sub) && !runId) {
1984
2581
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
1985
2582
  }
1986
- if (sub === "status")
2583
+ if (sub === "status") {
2584
+ if (flags["rejected-draft"] !== undefined && flags["rejected-draft"] !== false) {
2585
+ return printResult(await rejectedDraftFlow(runId, json, defaultDeps));
2586
+ }
1987
2587
  return printResult(await statusFlow(runId, json, defaultDeps));
2588
+ }
1988
2589
  if (sub === "storyboard")
1989
2590
  return printResult(await storyboardFlow(runId, json, defaultDeps));
1990
- if (sub === "approve")
1991
- return printResult(await approveFlow(runId, json, defaultDeps));
2591
+ if (sub === "approve") {
2592
+ return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2593
+ }
1992
2594
  if (sub === "flag") {
1993
2595
  const note = flagString(flags, "note");
1994
2596
  if (!note)
@@ -2018,7 +2620,7 @@ export async function run(flags, occurrences) {
2018
2620
  if (!Number.isInteger(sceneIndex)) {
2019
2621
  usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
2020
2622
  }
2021
- return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
2623
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
2022
2624
  }
2023
2625
  if (sub === "revoice") {
2024
2626
  const sceneRaw = flagString(flags, "scene");