@aicopycoders/exodus 2026.9.2000-beta.0 → 2026.9.2000-beta.10

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,38 +6,66 @@ 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
- exodus video — make an ad from a Show, pull every piece, upload your cut
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
 
17
33
  The whole loop, in order:
18
34
 
19
- 1. exodus video shows
20
- Which Shows exist and which are ready to make ads.
35
+ 1. exodus workflow list
36
+ Which workflows this brand has saved. A video workflow is one that makes
37
+ ads.
38
+
39
+ 2. exodus workflow describe <workflowId|name>
40
+ What that workflow needs before it can run, and what it gives back.
21
41
 
22
- 2. exodus video start --show <id> --script script.txt --wait
23
- Starts an ad and waits. It stops when the storyboard needs your yes.
42
+ 3. exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
43
+ Starts the ad on your script and waits. It stops when the storyboard
44
+ needs your yes. "describe" names the field your script goes in. You can
45
+ also settle the voices here, at the launch, instead of at step 5 — the
46
+ launch flags all live in: exodus workflow --help
24
47
 
25
- 3. exodus video storyboard <runId>
48
+ 4. exodus video storyboard <runId>
26
49
  The scene cards: what each scene says and the picture it will look like.
27
50
 
28
- 4. exodus video voices <runId>
51
+ 5. exodus video voices <runId>
29
52
  Who is in the ad, which voice each one has, how the voices get applied
30
53
  and who pays. Add --set to give someone a voice, before any clip is made.
31
54
 
32
- 5. exodus video approve <runId> (looks right — keep going)
33
- exodus video flag <runId> --note "…" (something is wrong — send it back)
55
+ 6. exodus video approve <runId> (looks right — keep going)
34
56
  exodus video retry-frame <runId> --node <nodeId> --scene <n>
35
57
  Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
36
58
 
37
- 6. exodus video status <runId>
38
- Where the run is and how each scene's clip turned out.
59
+ 7. exodus video status <runId>
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.
39
63
 
40
- 7. exodus video pull <runId> --out ./ad
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.
67
+
68
+ 8. exodus video pull <runId> --out ./ad
41
69
  Writes every piece to that folder plus a manifest.json index.
42
70
 
43
71
  exodus video retry-clip <runId> --scene <n>
@@ -50,41 +78,34 @@ The whole loop, in order:
50
78
  picture stays, no new video is made, and a clip whose voice pass
51
79
  cannot finish is kept as it was. Use --scene <n> for one clip.
52
80
 
53
- 8. Make your cut from those files.
81
+ 9. Make your cut from those files.
54
82
 
55
- 9. exodus video upload <runId> --file cut.mp4
56
- Attaches your cut to the run and prints the page to approve it on.
83
+ 10. exodus video upload <runId> --file cut.mp4
84
+ Attaches your cut to the run and prints the page to approve it on.
57
85
 
58
- 10. exodus video approve <runId>
59
- Or click Approve on the page from step 9.
86
+ 11. exodus video approve <runId>
87
+ Or click Approve on the page from step 10. If you redid a clip after
88
+ uploading, this stops and says which scenes changed. Upload a fresh cut,
89
+ or add --approve-stale-cut to deliver the cut you already uploaded.
60
90
 
61
91
  Usage:
62
- exodus video shows [--json]
63
- exodus video start --show <id> --script <file> [--voice-path <v>] [--no-music] [--wait] [--json]
64
- exodus video status <runId> [--json]
92
+ exodus video status <runId> [--rejected-draft] [--json]
65
93
  exodus video storyboard <runId> [--json]
66
- exodus video approve <runId> [--json]
67
- exodus video flag <runId> --note "<what is wrong>" [--json]
94
+ exodus video approve <runId> [--approve-stale-cut] [--json]
68
95
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
69
96
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
70
97
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
71
98
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
72
99
  exodus video pull <runId> --out <dir> [--json]
73
100
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
101
+ 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]
74
102
 
75
103
  Options:
76
- --show <id> Which Show the ad belongs to (from: exodus video shows)
77
- --script <file> A text file with the words you want the ad to say
78
- --voice-path <v> Override the Show's voice path for this ad
79
- --no-music Skip the music bed
80
- --wait Stay open and report as the run moves; stops at every
81
- point that needs you
82
104
  --out <dir> Folder to write the pulled pieces into (pull)
83
105
  --file <cut.mp4> Your finished cut (upload). MP4, MOV or WebM, up to 200MB
84
106
  --duration <sec> How long your cut is, in seconds. Only needed when the
85
107
  length can't be read off the file itself
86
- --note "<text>" What is wrong with the storyboard (flag), or how to
87
- steer one redo (retry-frame, retry-clip)
108
+ --note "<text>" How to steer one redo (retry-frame, retry-clip)
88
109
  --node <nodeId> Which scene-frames node holds the still (retry-frame).
89
110
  Which video step holds the clip, when a scene has one on
90
111
  more than one step (retry-clip, revoice)
@@ -92,21 +113,40 @@ Options:
92
113
  (retry-frame, retry-clip, revoice)
93
114
  --all Every finished clip that kept the video model's voice
94
115
  (revoice)
116
+ --approve-stale-cut Deliver the cut you uploaded even though a clip changed
117
+ after you uploaded it (approve). Without it, approving
118
+ stops and names the scenes that changed
95
119
  --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
96
120
  character by its ID or by the name your script uses.
97
121
  Repeat it once per character
98
122
  --clear <who> Take a character's voice off again (voices). Repeatable
99
123
  --from <file.json> A file of characters and voice IDs (voices). --set and
100
124
  --clear win over the same name in the file
125
+ --rejected-draft Print the storyboard draft the system turned down
126
+ (status). It was thrown away — a record only
127
+ --script <file> Text file of what the ad says (start)
128
+ --conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
129
+ --style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
130
+ --direction "<note>" Note for the whole ad (start)
131
+ --voice-path <path> How the voices are made (start)
132
+ --video-model <id> Which video model films this ad, instead of the kind of
133
+ ad's own default (start, with --conceit)
134
+ --voice <mode> native: the video model speaks the lines. voice-first:
135
+ the lines are recorded first (start, with --conceit)
136
+ --music Put a music bed under the ad (start). An ad has no music
137
+ unless you ask for it here
138
+ --no-music Leave the music bed off (start), which it already is
139
+ --wait Wait until the storyboard needs a yes (start)
101
140
  --json Machine-readable output
102
- --help, -h Show this help
141
+ --help, -h Print this help
103
142
 
104
143
  Video is admin-only. If every command here answers "video isn't enabled for
105
144
  this key", your dashboard user needs the admin role on this brand.
106
145
 
107
146
  Examples:
108
- exodus video shows
109
- exodus video start --show k57abc --script ./script.txt --wait
147
+ exodus workflow list
148
+ exodus workflow describe "Video Ad"
149
+ exodus workflow run "Video Ad" --input script=@./script.txt --wait
110
150
  exodus video storyboard run_123
111
151
  exodus video approve run_123
112
152
  exodus video retry-frame run_123 --node frames-1 --scene 2
@@ -120,8 +160,12 @@ Examples:
120
160
  `.trim();
121
161
  const SHOWS_PATH = "/api/v2/shows";
122
162
  const RUNS_PATH = "/api/v2/video/runs";
163
+ const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
123
164
  const RUN_PATH = "/api/v2/workflow";
124
165
  const ITEMS_PATH = "/api/v2/workflow/items";
166
+ const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
167
+ const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
168
+ "with the next server update.";
125
169
  const STORYBOARD_PATH = "/api/v2/video/storyboard";
126
170
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
127
171
  const APPROVE_PATH = "/api/v2/workflow/approve";
@@ -136,6 +180,48 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
136
180
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
137
181
  return rest;
138
182
  }
183
+ export function qcWithoutJudgeWording(qc) {
184
+ if (!qc.takes)
185
+ return qc;
186
+ return {
187
+ ...qc,
188
+ takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
189
+ };
190
+ }
191
+ export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
192
+ const TAKE_NEIGHBOUR_SENTENCE = {
193
+ "not-applicable": () => "never compared",
194
+ "not-attached": () => "no neighbours attached",
195
+ "none-accepted": () => "no accepted neighbours yet",
196
+ attached: ({ scenes }) => `compared against scene${scenes.length === 1 ? "" : "s"} ${scenes.join(", ")}`,
197
+ };
198
+ export function renderQcTakeHistory(takes) {
199
+ if (!takes || takes.length === 0)
200
+ return [NO_TAKE_HISTORY_LINE];
201
+ const lines = [];
202
+ takes.forEach((take, index) => {
203
+ const say = TAKE_NEIGHBOUR_SENTENCE[take.neighbours.state];
204
+ const parts = [
205
+ take.failCodes.length ? `failed ${take.failCodes.join(", ")}` : "passed",
206
+ ...(take.warnCodes.length ? [`warned ${take.warnCodes.join(", ")}`] : []),
207
+ say(take.neighbours),
208
+ ];
209
+ const bonus = take.kind === "soft-retry" ? " (bonus take)" : "";
210
+ lines.push(`take ${index + 1}${bonus}: ${parts.join("; ")}`);
211
+ for (const { code, wording } of take.judgeWording ?? []) {
212
+ lines.push(` checker said (${code}): ${wording}`);
213
+ }
214
+ });
215
+ return lines;
216
+ }
217
+ function printDisplacedHistory(lines, lastRedo) {
218
+ const history = lastRedo.displacedHistory;
219
+ if (!history)
220
+ return;
221
+ lines.push(` ${history.title}`);
222
+ for (const line of history.lines)
223
+ lines.push(` ${line}`);
224
+ }
139
225
  export function asVideoRun(data) {
140
226
  const run = (data ?? {});
141
227
  return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
@@ -248,6 +334,7 @@ export function classifyRun(run) {
248
334
  ...(pausedNode.kind === "scene-frames"
249
335
  ? { framesNodeId: pausedNode.nodeId }
250
336
  : {}),
337
+ ...(isShowAd(run) ? { showAd: true } : {}),
251
338
  };
252
339
  }
253
340
  if (parkedAtFinalWatch(run))
@@ -257,6 +344,22 @@ export function classifyRun(run) {
257
344
  const active = run.nodes.find((n) => n.status === "running");
258
345
  return { at: "running", stage: active?.kind ?? "starting" };
259
346
  }
347
+ export function hasAttachedCut(items) {
348
+ return items.some((i) => i.itemKind === "final" && i.status === "done");
349
+ }
350
+ export function resolveStop(stop, cutAttached) {
351
+ if (stop.at !== "final-watch")
352
+ return stop;
353
+ return { at: "final-watch", cutAttached };
354
+ }
355
+ export async function resolveStopAtPark(stop, runId, deps) {
356
+ if (stop.at !== "final-watch")
357
+ return stop;
358
+ const res = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
359
+ if (!res.ok)
360
+ return resolveStop(stop, null);
361
+ return resolveStop(stop, hasAttachedCut(res.data.items ?? []));
362
+ }
260
363
  const STAGE_WORDS = {
261
364
  brief: "reading the script",
262
365
  storyboard: "writing the storyboard",
@@ -282,8 +385,11 @@ export function stepName(kind) {
282
385
  return "A step in this run";
283
386
  return STEP_NAMES[kind] ?? `The ${kind} step`;
284
387
  }
388
+ export function isShowAd(run) {
389
+ return run.moduleOwned === true;
390
+ }
285
391
  export function reviewUrl(dashboardUrl, run) {
286
- if (run.moduleOwned === true)
392
+ if (isShowAd(run))
287
393
  return `${dashboardUrl}/video?ad=${run._id}`;
288
394
  if (run.workflowId)
289
395
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
@@ -295,14 +401,31 @@ export function stopLines(stop, runId, runUrl) {
295
401
  "Parked: the storyboard is waiting for your yes.",
296
402
  `Read it: exodus video storyboard ${runId}`,
297
403
  `Approve it: exodus video approve ${runId}`,
298
- `Send back: exodus video flag ${runId} --note "what's wrong"`,
299
404
  ];
405
+ if (stop.showAd) {
406
+ lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
407
+ }
300
408
  if (stop.framesNodeId) {
301
409
  lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
302
410
  }
303
411
  return lines;
304
412
  }
305
413
  if (stop.at === "final-watch") {
414
+ if (stop.cutAttached === null) {
415
+ return [
416
+ "Parked: every piece is made.",
417
+ `Couldn't check whether a cut is already uploaded. See where it stands: exodus video status ${runId}`,
418
+ `Watch it here: ${runUrl}`,
419
+ ];
420
+ }
421
+ if (stop.cutAttached) {
422
+ return [
423
+ "Parked: your cut is uploaded and waiting for your approval.",
424
+ `Approve it: exodus video approve ${runId}`,
425
+ `Watch it here: ${runUrl}`,
426
+ "Changed your mind? You can still redo a clip and upload a new cut.",
427
+ ];
428
+ }
306
429
  return [
307
430
  "Parked: every piece is made and the run is waiting for a cut.",
308
431
  `Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
@@ -313,6 +436,13 @@ export function stopLines(stop, runId, runUrl) {
313
436
  }
314
437
  if (stop.at === "failed") {
315
438
  if (stop.repair) {
439
+ if (!stop.step) {
440
+ return [
441
+ "This run stopped at a step that can't be picked back up from here.",
442
+ "Start a fresh run to get this ad made.",
443
+ `Open the run: ${runUrl}`,
444
+ ];
445
+ }
316
446
  return [
317
447
  `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
318
448
  `Try that step again: exodus workflow repair ${runId} retry`,
@@ -338,7 +468,40 @@ export function stopLines(stop, runId, runUrl) {
338
468
  }
339
469
  return [`Working: ${stageWord(stop.stage)}.`];
340
470
  }
341
- export const CAST_LEDGER_BASE = 910000;
471
+ export function failedStoryboardNode(run) {
472
+ return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
473
+ }
474
+ export function errorEchoesReasons(error, record) {
475
+ if (!error || record?.kind !== "rejected")
476
+ return false;
477
+ const reasons = record.reasons ?? [];
478
+ return reasons.length > 0 && reasons.every((reason) => error.includes(reason));
479
+ }
480
+ export function planFailureLines(node, runId) {
481
+ const record = node?.planFailure;
482
+ if (!record || !Array.isArray(record.lines) || !node)
483
+ return [];
484
+ const reasons = record.kind === "rejected" ? (record.reasons ?? []) : [];
485
+ const bodyCount = Math.max(0, record.lines.length - reasons.length);
486
+ const lines = [`What happened to the storyboard step (${node.nodeId}):`];
487
+ record.lines.forEach((line, i) => {
488
+ lines.push(i < bodyCount ? ` ${line}` : ` - ${line}`);
489
+ });
490
+ if (node?.hasRejectedDraft === true) {
491
+ 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`);
492
+ }
493
+ return lines;
494
+ }
495
+ export const MUSIC_HEARD_CODE = "music-heard";
496
+ export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
497
+ export function musicBedState(run) {
498
+ if (run.musicBed === undefined)
499
+ return "unknown";
500
+ return run.musicBed ? "on" : "off";
501
+ }
502
+ function isPullSceneIndex(sceneIndex) {
503
+ return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
504
+ }
342
505
  const FALLBACK_EXT = {
343
506
  image: "png",
344
507
  video: "mp4",
@@ -355,6 +518,10 @@ function extFor(url, family) {
355
518
  export function scenePrefix(sceneIndex) {
356
519
  return `scene-${String(sceneIndex).padStart(2, "0")}`;
357
520
  }
521
+ function clipQcOf(item) {
522
+ const artifact = item?.artifact;
523
+ return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
524
+ }
358
525
  function outputsOfNodeKind(run, kind) {
359
526
  return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
360
527
  }
@@ -384,7 +551,9 @@ function keyframeDownload(sceneIndex, imageUrl) {
384
551
  function reservedCastFrames(items) {
385
552
  const byIndex = new Map();
386
553
  for (const item of items) {
387
- if (item.itemKind === "frame" && item.sceneIndex >= CAST_LEDGER_BASE) {
554
+ if (item.itemKind === "frame" &&
555
+ item.sceneIndex >= CAST_LEDGER_BASE &&
556
+ item.sceneIndex < SET_OPTION_LEDGER_BASE) {
388
557
  byIndex.set(item.sceneIndex, item);
389
558
  }
390
559
  }
@@ -537,6 +706,8 @@ export function planPull(run, items, opts) {
537
706
  for (const frame of artifact.frames ?? []) {
538
707
  if (!frame.imageUrl)
539
708
  continue;
709
+ if (!isPullSceneIndex(frame.sceneIndex))
710
+ continue;
540
711
  keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
541
712
  }
542
713
  }
@@ -554,7 +725,7 @@ export function planPull(run, items, opts) {
554
725
  };
555
726
  continue;
556
727
  }
557
- if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex < 0)
728
+ if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
558
729
  continue;
559
730
  voiceByScene.set(artifact.sceneIndex, {
560
731
  download: {
@@ -595,6 +766,8 @@ export function planPull(run, items, opts) {
595
766
  }
596
767
  if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
597
768
  continue;
769
+ if (!isPullSceneIndex(artifact.sceneIndex))
770
+ continue;
598
771
  const clip = clipFromArtifact(artifact.sceneIndex, artifact);
599
772
  if (!clip)
600
773
  continue;
@@ -604,6 +777,8 @@ export function planPull(run, items, opts) {
604
777
  const frameItemByScene = new Map();
605
778
  const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
606
779
  for (const item of items) {
780
+ if (!isPullSceneIndex(item.sceneIndex))
781
+ continue;
607
782
  if (item.itemKind === "clip")
608
783
  clipItemByScene.set(item.sceneIndex, item);
609
784
  if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
@@ -635,7 +810,7 @@ export function planPull(run, items, opts) {
635
810
  ...frameItemByScene.keys(),
636
811
  ]),
637
812
  ]
638
- .filter((sceneIndex) => sceneIndex >= 0 && sceneIndex < CAST_LEDGER_BASE)
813
+ .filter((sceneIndex) => isPullSceneIndex(sceneIndex))
639
814
  .sort((a, b) => a - b);
640
815
  const scenes = sceneIndexes.map((sceneIndex) => {
641
816
  const clip = clipByScene.get(sceneIndex);
@@ -670,7 +845,7 @@ export function planPull(run, items, opts) {
670
845
  : "this-file",
671
846
  voice: voice?.download.file ?? null,
672
847
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
673
- qc: clip?.qc ?? null,
848
+ qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
674
849
  revoiced: clip ? clip.revoiced : null,
675
850
  speechTrimmed: clip ? clip.speechTrimmed : null,
676
851
  rawStorageId: clip ? clip.rawStorageId : null,
@@ -701,6 +876,10 @@ export function planPull(run, items, opts) {
701
876
  storyboard,
702
877
  reference: reference?.file ?? null,
703
878
  music: music?.file ?? null,
879
+ musicBed: musicBedState(run),
880
+ musicHeardScenes: scenes
881
+ .filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
882
+ .map((scene) => scene.sceneIndex),
704
883
  cast,
705
884
  narration: narrationDownload
706
885
  ? { file: narrationDownload.file, timing: "narration.json" }
@@ -710,6 +889,14 @@ export function planPull(run, items, opts) {
710
889
  ...(run.provenance?.format || run.provenance?.voice
711
890
  ? { provenance: run.provenance }
712
891
  : {}),
892
+ ...(run.videoChoice
893
+ ? {
894
+ videoChoice: {
895
+ videoModel: run.videoChoice.videoModel,
896
+ voiceMode: run.videoChoice.voiceMode,
897
+ },
898
+ }
899
+ : {}),
713
900
  },
714
901
  };
715
902
  }
@@ -840,7 +1027,7 @@ export async function startFlow(opts, deps) {
840
1027
  showId: opts.showId,
841
1028
  script,
842
1029
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
843
- ...(opts.music === false ? { music: false } : {}),
1030
+ ...(opts.music === undefined ? {} : { music: opts.music }),
844
1031
  });
845
1032
  if (!res.ok)
846
1033
  return errorResult(res, opts.json);
@@ -869,6 +1056,264 @@ export async function startFlow(opts, deps) {
869
1056
  lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
870
1057
  };
871
1058
  }
1059
+ function nonBlank(value) {
1060
+ if (value === undefined || value.trim().length === 0)
1061
+ return undefined;
1062
+ return value;
1063
+ }
1064
+ function isVoiceMode(value) {
1065
+ return VOICE_MODES.some((mode) => mode === value);
1066
+ }
1067
+ function isConceit(value) {
1068
+ return CONCEIT_KEYS.some((key) => key === value);
1069
+ }
1070
+ export function planMusicChoice(occurrences) {
1071
+ let music;
1072
+ for (const { flag, value } of occurrences) {
1073
+ if (flag === "no-music") {
1074
+ music = false;
1075
+ continue;
1076
+ }
1077
+ if (flag !== "music")
1078
+ continue;
1079
+ if (value === undefined || value.startsWith("--")) {
1080
+ music = true;
1081
+ continue;
1082
+ }
1083
+ const word = value.trim().toLowerCase();
1084
+ const literal = word === "true" ? true : word === "false" ? false : undefined;
1085
+ if (literal === undefined) {
1086
+ return {
1087
+ ok: false,
1088
+ line: `video start --music takes true or false, or no value at all (got "${value}").`,
1089
+ };
1090
+ }
1091
+ music = literal;
1092
+ }
1093
+ return { ok: true, music };
1094
+ }
1095
+ function planValueFlag(occurrences, name) {
1096
+ let value;
1097
+ for (const occurrence of occurrences) {
1098
+ if (occurrence.flag !== name)
1099
+ continue;
1100
+ const given = occurrence.value;
1101
+ if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
1102
+ return { ok: false, line: `video start --${name} needs a value.` };
1103
+ }
1104
+ value = given.trim();
1105
+ }
1106
+ return { ok: true, value };
1107
+ }
1108
+ export function planVideoStart(flags, occurrences) {
1109
+ const showId = nonBlank(flagString(flags, "show"));
1110
+ const scriptFile = nonBlank(flagString(flags, "script"));
1111
+ const conceit = nonBlank(flagString(flags, "conceit"));
1112
+ const style = nonBlank(flagString(flags, "style"));
1113
+ if (showId && (conceit || style)) {
1114
+ return {
1115
+ kind: "usage",
1116
+ line: "Pass either --show <id>, or --conceit and --style. Not both.",
1117
+ };
1118
+ }
1119
+ if (!scriptFile) {
1120
+ return {
1121
+ kind: "usage",
1122
+ line: "video start needs --script <file>, a text file of what the ad says.",
1123
+ };
1124
+ }
1125
+ const musicChoice = planMusicChoice(occurrences);
1126
+ if (!musicChoice.ok)
1127
+ return { kind: "usage", line: musicChoice.line };
1128
+ const videoModelFlag = planValueFlag(occurrences, "video-model");
1129
+ if (!videoModelFlag.ok)
1130
+ return { kind: "usage", line: videoModelFlag.line };
1131
+ const voiceFlag = planValueFlag(occurrences, "voice");
1132
+ if (!voiceFlag.ok)
1133
+ return { kind: "usage", line: voiceFlag.line };
1134
+ const videoModel = videoModelFlag.value;
1135
+ const voiceMode = voiceFlag.value;
1136
+ if (showId && (videoModel || voiceMode)) {
1137
+ return {
1138
+ kind: "usage",
1139
+ line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
1140
+ };
1141
+ }
1142
+ if (voiceMode && !isVoiceMode(voiceMode)) {
1143
+ return {
1144
+ kind: "usage",
1145
+ line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
1146
+ };
1147
+ }
1148
+ if (showId) {
1149
+ return {
1150
+ kind: "show",
1151
+ opts: {
1152
+ showId,
1153
+ scriptFile,
1154
+ voicePath: flagString(flags, "voice-path"),
1155
+ music: musicChoice.music,
1156
+ wait: flags["wait"] === true,
1157
+ json: flags["json"] === true,
1158
+ },
1159
+ };
1160
+ }
1161
+ if (!conceit) {
1162
+ return {
1163
+ kind: "usage",
1164
+ line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
1165
+ };
1166
+ }
1167
+ if (!isConceit(conceit)) {
1168
+ return {
1169
+ kind: "usage",
1170
+ line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
1171
+ };
1172
+ }
1173
+ if (!style) {
1174
+ return {
1175
+ kind: "usage",
1176
+ line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
1177
+ };
1178
+ }
1179
+ const direction = nonBlank(flagString(flags, "direction"));
1180
+ const voicePath = nonBlank(flagString(flags, "voice-path"));
1181
+ const opts = {
1182
+ scriptFile,
1183
+ conceit,
1184
+ style,
1185
+ wait: flags["wait"] === true,
1186
+ json: flags["json"] === true,
1187
+ };
1188
+ if (direction)
1189
+ opts.direction = direction;
1190
+ if (voicePath)
1191
+ opts.voicePath = voicePath;
1192
+ if (videoModel)
1193
+ opts.videoModel = videoModel;
1194
+ if (voiceMode && isVoiceMode(voiceMode))
1195
+ opts.voiceMode = voiceMode;
1196
+ if (musicChoice.music !== undefined)
1197
+ opts.music = musicChoice.music;
1198
+ return { kind: "script", opts };
1199
+ }
1200
+ function isRecord(value) {
1201
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1202
+ }
1203
+ function isResolvedCard(value) {
1204
+ if (!isRecord(value))
1205
+ return false;
1206
+ if (!isRecord(value.conceit) || !isRecord(value.style))
1207
+ return false;
1208
+ if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
1209
+ return false;
1210
+ if (typeof value.family !== "string")
1211
+ return false;
1212
+ if (!("wrapper" in value))
1213
+ return false;
1214
+ if (value.wrapper !== null && typeof value.wrapper !== "string")
1215
+ return false;
1216
+ if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
1217
+ return false;
1218
+ return true;
1219
+ }
1220
+ function startedVideoChoice(value) {
1221
+ if (!isRecord(value))
1222
+ return null;
1223
+ if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
1224
+ return null;
1225
+ return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1226
+ }
1227
+ function scriptStartedLines(runId, url, card, video) {
1228
+ return [
1229
+ `Started ad run ${runId}`,
1230
+ ...(card
1231
+ ? [
1232
+ `Conceit: ${card.conceit.label}`,
1233
+ `Family: ${card.family}`,
1234
+ `Wrapper: ${card.wrapper ?? ""}`,
1235
+ `Style: ${card.style.label}`,
1236
+ ]
1237
+ : ["The server did not include a resolved card."]),
1238
+ ...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
1239
+ `Watch it: ${url}`,
1240
+ ];
1241
+ }
1242
+ function waitJsonWithCard(waited, card, video) {
1243
+ const first = waited.lines[0];
1244
+ if (typeof first !== "string")
1245
+ return waited;
1246
+ let parsed;
1247
+ try {
1248
+ parsed = JSON.parse(first);
1249
+ }
1250
+ catch {
1251
+ return waited;
1252
+ }
1253
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1254
+ return waited;
1255
+ return {
1256
+ code: waited.code,
1257
+ lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1258
+ };
1259
+ }
1260
+ export async function startScriptFlow(opts, deps) {
1261
+ let script;
1262
+ try {
1263
+ script = deps.readFile(opts.scriptFile);
1264
+ }
1265
+ catch (e) {
1266
+ return {
1267
+ code: 1,
1268
+ lines: [
1269
+ `Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
1270
+ ],
1271
+ };
1272
+ }
1273
+ if (script.trim().length === 0) {
1274
+ return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
1275
+ }
1276
+ const res = await deps.post(SCRIPT_RUNS_PATH, {
1277
+ script,
1278
+ conceit: opts.conceit,
1279
+ style: opts.style,
1280
+ ...(opts.direction ? { direction: opts.direction } : {}),
1281
+ ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1282
+ ...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
1283
+ ...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
1284
+ ...(opts.music === true ? { music: true } : {}),
1285
+ });
1286
+ if (!res.ok)
1287
+ return errorResult(res, opts.json);
1288
+ const started = res.data;
1289
+ if (!started.runId) {
1290
+ return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
1291
+ }
1292
+ const runId = started.runId;
1293
+ const url = typeof started.url === "string" ? started.url : "";
1294
+ const card = isResolvedCard(started.card) ? started.card : null;
1295
+ const video = startedVideoChoice(started.videoChoice);
1296
+ if (!opts.wait) {
1297
+ return {
1298
+ code: 0,
1299
+ lines: opts.json
1300
+ ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1301
+ : [
1302
+ ...scriptStartedLines(runId, url, card, video),
1303
+ "",
1304
+ "Wait for the storyboard here instead: exodus video start … --wait",
1305
+ `Or check in whenever: exodus video status ${runId}`,
1306
+ ],
1307
+ };
1308
+ }
1309
+ const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1310
+ if (opts.json)
1311
+ return waitJsonWithCard(waited, card, video);
1312
+ return {
1313
+ code: waited.code,
1314
+ lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
1315
+ };
1316
+ }
872
1317
  const POLL_INTERVAL_MS = 5000;
873
1318
  const MAX_POLLS = 720;
874
1319
  export async function waitFlow(runId, opts, deps) {
@@ -894,15 +1339,16 @@ export async function waitFlow(runId, opts, deps) {
894
1339
  }
895
1340
  continue;
896
1341
  }
1342
+ const resolved = await resolveStopAtPark(stop, runId, deps);
897
1343
  if (opts.json) {
898
1344
  return {
899
1345
  code: stop.at === "failed" ? 1 : 0,
900
- lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
1346
+ lines: [JSON.stringify({ runId, stop: resolved, status: run.status, url: opts.url })],
901
1347
  };
902
1348
  }
903
1349
  return {
904
1350
  code: stop.at === "failed" ? 1 : 0,
905
- lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
1351
+ lines: [...lines, ...stopLines(resolved, runId, reviewUrl(deps.dashboardUrl, run))],
906
1352
  };
907
1353
  }
908
1354
  const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
@@ -927,6 +1373,11 @@ function itemWord(item) {
927
1373
  return "flagged";
928
1374
  return ITEM_STATUS_WORD[item.status] ?? item.status;
929
1375
  }
1376
+ 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.";
1377
+ function carryingOn(run) {
1378
+ return (run.status === "queued" &&
1379
+ run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
1380
+ }
930
1381
  export async function statusFlow(runId, json, deps) {
931
1382
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
932
1383
  if (!runRes.ok)
@@ -936,15 +1387,35 @@ export async function statusFlow(runId, json, deps) {
936
1387
  if (!itemsRes.ok)
937
1388
  return errorResult(itemsRes, json);
938
1389
  const items = itemsRes.data.items ?? [];
939
- const stop = classifyRun(run);
940
- const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
1390
+ const hasFinal = hasAttachedCut(items);
1391
+ const stop = resolveStop(classifyRun(run), hasFinal);
1392
+ const failedStoryboard = failedStoryboardNode(run);
1393
+ const guidance = stopLines(stop.at === "failed" && errorEchoesReasons(stop.error, failedStoryboard?.planFailure)
1394
+ ? { ...stop, error: undefined }
1395
+ : stop, runId, reviewUrl(deps.dashboardUrl, run));
941
1396
  const warnings = run.nodes
942
1397
  .filter((n) => n.warning)
943
1398
  .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
944
1399
  if (json) {
945
1400
  return {
946
1401
  code: 0,
947
- lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
1402
+ lines: [
1403
+ JSON.stringify({
1404
+ runId,
1405
+ status: run.status,
1406
+ stop,
1407
+ items,
1408
+ hasFinal,
1409
+ warnings,
1410
+ guidance,
1411
+ ...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
1412
+ ...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
1413
+ ...(failedStoryboard?.planFailure
1414
+ ? { storyboardNodeId: failedStoryboard.nodeId }
1415
+ : {}),
1416
+ ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1417
+ }),
1418
+ ],
948
1419
  };
949
1420
  }
950
1421
  const revoicedByScene = new Set();
@@ -975,8 +1446,10 @@ export async function statusFlow(runId, json, deps) {
975
1446
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
976
1447
  const lines = [
977
1448
  headline,
978
- ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
1449
+ ...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
1450
+ ...guidance,
979
1451
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1452
+ ...planFailureLines(failedStoryboard, runId),
980
1453
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
981
1454
  ];
982
1455
  if (byScene.size === 0) {
@@ -987,6 +1460,21 @@ export async function statusFlow(runId, json, deps) {
987
1460
  for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
988
1461
  const row = byScene.get(sceneIndex);
989
1462
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1463
+ if (row.clip?.lastRedo) {
1464
+ lines.push(` clip: ${row.clip.lastRedo.label}`);
1465
+ printDisplacedHistory(lines, row.clip.lastRedo);
1466
+ }
1467
+ if (row.clip?.lastRedo || row.clip?.flagged) {
1468
+ for (const line of renderQcTakeHistory(clipQcOf(row.clip))) {
1469
+ lines.push(` ${line}`);
1470
+ }
1471
+ }
1472
+ if (row.voiceover?.lastRedo) {
1473
+ lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1474
+ printDisplacedHistory(lines, row.voiceover.lastRedo);
1475
+ }
1476
+ if (row.frame?.lastRedo)
1477
+ lines.push(` picture: ${row.frame.lastRedo.label}`);
990
1478
  for (const finding of row.clip?.findings ?? []) {
991
1479
  lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
992
1480
  const judgeSeverity = finding.judgeSeverity ?? finding.severity;
@@ -1019,6 +1507,45 @@ export async function statusFlow(runId, json, deps) {
1019
1507
  : "Final cut: not uploaded yet.");
1020
1508
  return { code: 0, lines };
1021
1509
  }
1510
+ export async function rejectedDraftFlow(runId, json, deps) {
1511
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1512
+ if (!runRes.ok)
1513
+ return errorResult(runRes, json);
1514
+ const node = failedStoryboardNode(asVideoRun(runRes.data));
1515
+ if (!node) {
1516
+ const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
1517
+ return {
1518
+ code: 0,
1519
+ lines: json ? [JSON.stringify({ runId, nodeId: null, draft: null })] : [note],
1520
+ };
1521
+ }
1522
+ const res = await deps.get(`${REJECTED_DRAFT_PATH}?runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(node.nodeId)}`);
1523
+ if (missingRouteLine(res, "exodus video status --rejected-draft")) {
1524
+ return {
1525
+ code: 1,
1526
+ lines: json
1527
+ ? [JSON.stringify({ ok: false, status: 404, error: REJECTED_DRAFT_NOT_ON_THIS_SERVER })]
1528
+ : [REJECTED_DRAFT_NOT_ON_THIS_SERVER],
1529
+ };
1530
+ }
1531
+ if (!res.ok)
1532
+ return errorResult(res, json);
1533
+ const draft = res.data.draft ?? null;
1534
+ if (json)
1535
+ return { code: 0, lines: [JSON.stringify({ runId, nodeId: node.nodeId, draft })] };
1536
+ if (!draft)
1537
+ return { code: 0, lines: ["No rejected draft was kept for this attempt."] };
1538
+ return {
1539
+ code: 0,
1540
+ lines: [
1541
+ `REJECTED DRAFT — ad run ${runId}, step ${node.nodeId}`,
1542
+ "The system turned this draft down and threw it away. It is kept only as a record of what happened.",
1543
+ "Nothing in it is part of your ad. There is nothing here to act on or edit.",
1544
+ "",
1545
+ ...draft.split("\n"),
1546
+ ],
1547
+ };
1548
+ }
1022
1549
  export async function storyboardFlow(runId, json, deps) {
1023
1550
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
1024
1551
  if (!res.ok)
@@ -1070,7 +1597,14 @@ export async function storyboardFlow(runId, json, deps) {
1070
1597
  }
1071
1598
  }
1072
1599
  }
1073
- lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
1600
+ const runRes = await deps
1601
+ .get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`)
1602
+ .catch(() => null);
1603
+ const showAd = runRes?.ok === true && isShowAd(asVideoRun(runRes.data));
1604
+ lines.push("", `approve with: exodus video approve ${runId}`);
1605
+ if (showAd) {
1606
+ lines.push(`send it back: exodus video flag ${runId} --note "what's wrong"`);
1607
+ }
1074
1608
  if (cards.framesNodeId) {
1075
1609
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
1076
1610
  }
@@ -1098,14 +1632,30 @@ function sceneCardLines(scene) {
1098
1632
  lines.push(` picture: ${scene.frame.status}`);
1099
1633
  return lines;
1100
1634
  }
1101
- export async function approveFlow(runId, json, deps) {
1102
- const res = await deps.post(APPROVE_PATH, { runId });
1103
- if (!res.ok)
1104
- return errorResult(res, json);
1105
- if (json)
1106
- return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
1107
- const voices = res.data
1108
- ?.voices;
1635
+ export async function approveFlow(runId, opts, deps) {
1636
+ const res = await deps.post(APPROVE_PATH, {
1637
+ runId,
1638
+ ...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
1639
+ });
1640
+ if (!res.ok) {
1641
+ const refusal = errorResult(res, opts.json);
1642
+ const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
1643
+ return staleCut
1644
+ ? {
1645
+ ...refusal,
1646
+ lines: [
1647
+ ...refusal.lines,
1648
+ `To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
1649
+ ],
1650
+ }
1651
+ : refusal;
1652
+ }
1653
+ const data = res.data;
1654
+ const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
1655
+ if (opts.json) {
1656
+ return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
1657
+ }
1658
+ const voices = data?.voices;
1109
1659
  const summary = voices
1110
1660
  ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1111
1661
  : [];
@@ -1113,6 +1663,7 @@ export async function approveFlow(runId, json, deps) {
1113
1663
  code: 0,
1114
1664
  lines: [
1115
1665
  "Approved.",
1666
+ ...warnings,
1116
1667
  ...(summary.length > 0 ? ["", ...summary, ""] : []),
1117
1668
  `See what happens next: exodus video status ${runId}`,
1118
1669
  ],
@@ -1170,8 +1721,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1170
1721
  ],
1171
1722
  };
1172
1723
  }
1173
- const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
1174
- "re-cut, and upload again.";
1724
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
1725
+ "exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
1726
+ "or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
1175
1727
  export function planClipRedo(run, items, target) {
1176
1728
  const stop = classifyRun(run);
1177
1729
  const found = findClipRow(run, items, target);
@@ -1204,7 +1756,7 @@ export function planClipRedo(run, items, target) {
1204
1756
  };
1205
1757
  }
1206
1758
  function findClipRow(run, items, target) {
1207
- const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
1759
+ const uploadedCut = hasAttachedCut(items);
1208
1760
  const rows = items.filter((i) => i.itemKind === "clip" &&
1209
1761
  i.sceneIndex === target.sceneIndex &&
1210
1762
  (target.nodeId === undefined || i.nodeId === target.nodeId));
@@ -1603,6 +2155,13 @@ export function voiceSheetLines(sheet) {
1603
2155
  : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
1604
2156
  return lines;
1605
2157
  }
2158
+ function musicLines(manifest) {
2159
+ const lines = [`music: ${manifest.musicBed}`];
2160
+ if (manifest.musicHeardScenes.length > 0) {
2161
+ lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
2162
+ }
2163
+ return lines;
2164
+ }
1606
2165
  export async function pullFlow(runId, dir, json, deps) {
1607
2166
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1608
2167
  if (!runRes.ok)
@@ -1646,6 +2205,7 @@ export async function pullFlow(runId, dir, json, deps) {
1646
2205
  const lines = [
1647
2206
  `Pulled ${wrote} files into ${dir}`,
1648
2207
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2208
+ ...musicLines(plan.manifest),
1649
2209
  ];
1650
2210
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
1651
2211
  if (flagged.length > 0) {
@@ -1902,7 +2462,12 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
1902
2462
  const VALUE_FLAGS = new Set([
1903
2463
  "show",
1904
2464
  "script",
2465
+ "conceit",
2466
+ "style",
2467
+ "direction",
1905
2468
  "voice-path",
2469
+ "video-model",
2470
+ "voice",
1906
2471
  "note",
1907
2472
  "out",
1908
2473
  "file",
@@ -1955,20 +2520,12 @@ export async function run(flags, occurrences) {
1955
2520
  if (sub === "shows")
1956
2521
  return printResult(await showsFlow(json, defaultDeps));
1957
2522
  if (sub === "start") {
1958
- const showId = flagString(flags, "show");
1959
- const scriptFile = flagString(flags, "script");
1960
- if (!showId)
1961
- usage("video start needs --show <id>. List them with: exodus video shows");
1962
- if (!scriptFile)
1963
- usage("video start needs --script <file>, a text file of what the ad says.");
1964
- return printResult(await startFlow({
1965
- showId,
1966
- scriptFile,
1967
- voicePath: flagString(flags, "voice-path"),
1968
- music: flags["music"] === false ? false : undefined,
1969
- wait: flags["wait"] === true,
1970
- json,
1971
- }, defaultDeps));
2523
+ const plan = planVideoStart(flags, occurrences);
2524
+ if (plan.kind === "usage")
2525
+ usage(plan.line);
2526
+ if (plan.kind === "show")
2527
+ return printResult(await startFlow(plan.opts, defaultDeps));
2528
+ return printResult(await startScriptFlow(plan.opts, defaultDeps));
1972
2529
  }
1973
2530
  const runId = rest[0];
1974
2531
  const needsRunId = [
@@ -1986,12 +2543,17 @@ export async function run(flags, occurrences) {
1986
2543
  if (needsRunId.includes(sub) && !runId) {
1987
2544
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
1988
2545
  }
1989
- if (sub === "status")
2546
+ if (sub === "status") {
2547
+ if (flags["rejected-draft"] !== undefined && flags["rejected-draft"] !== false) {
2548
+ return printResult(await rejectedDraftFlow(runId, json, defaultDeps));
2549
+ }
1990
2550
  return printResult(await statusFlow(runId, json, defaultDeps));
2551
+ }
1991
2552
  if (sub === "storyboard")
1992
2553
  return printResult(await storyboardFlow(runId, json, defaultDeps));
1993
- if (sub === "approve")
1994
- return printResult(await approveFlow(runId, json, defaultDeps));
2554
+ if (sub === "approve") {
2555
+ return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2556
+ }
1995
2557
  if (sub === "flag") {
1996
2558
  const note = flagString(flags, "note");
1997
2559
  if (!note)