@aicopycoders/exodus 2026.9.2000-beta.8 → 2026.9.2000

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.
@@ -7,11 +7,30 @@ 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
9
  import { HELPER_LEDGER_BASE, SET_OPTION_LEDGER_BASE } from "../lib/helperLedgerBase.js";
10
+ import { customerAnswer, customerRunPage, customerStageIsWorking, customerStageLines, customerStatusLines, customerStoryboardLines, customerVoicesLines, } from "./video-customer.js";
11
+ const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
12
+ const VOICE_MODES = ["native", "voice-first"];
13
+ const STYLE_SLUGS = [
14
+ "pixar-3d",
15
+ "claymation-stop-motion",
16
+ "lego-toy-brick",
17
+ "balloon",
18
+ "video-game-8bit",
19
+ ];
20
+ function joinedOr(items) {
21
+ if (items.length <= 1)
22
+ return items[0] ?? "";
23
+ return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
24
+ }
25
+ function cantSayNames(names) {
26
+ return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
27
+ }
10
28
  export const helpText = `
11
29
  exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
12
30
 
13
31
  The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
14
- one voice track per scene, one video clip per scene, plus the music bed. It does
32
+ one voice track per scene, one video clip per scene, and a music bed if you
33
+ asked for one with --music. It does
15
34
  not make the finished ad. You pull the pieces to a folder, cut them together
16
35
  with whatever editing tools you like, and upload the cut back.
17
36
 
@@ -55,7 +74,9 @@ The whole loop, in order:
55
74
 
56
75
  exodus video retry-clip <runId> --scene <n>
57
76
  Redo ONE finished clip while the run waits for the cut. Everything
58
- else stays. A --note steers the motion, never the words.
77
+ else stays. A --note steers the motion, never the words. Add
78
+ --voice-first when the words came out wrong: the line is recorded in
79
+ the character's own voice first, then the clip is made to match it.
59
80
 
60
81
  exodus video revoice <runId> --all
61
82
  Redo only the VOICE on clips that kept the video model's own voice
@@ -66,7 +87,8 @@ The whole loop, in order:
66
87
  9. Make your cut from those files.
67
88
 
68
89
  10. exodus video upload <runId> --file cut.mp4
69
- Attaches your cut to the run and prints the page to approve it on.
90
+ Attaches your cut to the run and prints the page to watch it on. On a
91
+ One-shot run the upload delivers the ad, so step 11 is not needed.
70
92
 
71
93
  11. exodus video approve <runId>
72
94
  Or click Approve on the page from step 10. If you redid a clip after
@@ -77,12 +99,14 @@ Usage:
77
99
  exodus video status <runId> [--rejected-draft] [--json]
78
100
  exodus video storyboard <runId> [--json]
79
101
  exodus video approve <runId> [--approve-stale-cut] [--json]
80
- exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
81
- exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
102
+ exodus video retry-frame <runId> [--node <nodeId>] --scene <n> [--note "..."] [--json]
103
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
82
104
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
83
105
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
84
106
  exodus video pull <runId> --out <dir> [--json]
85
107
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
108
+ exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--review-storyboard] [--director] [--wait] [--json]
109
+ exodus video director <runId> <on|off|follow> [--json]
86
110
 
87
111
  Options:
88
112
  --out <dir> Folder to write the pulled pieces into (pull)
@@ -90,13 +114,17 @@ Options:
90
114
  --duration <sec> How long your cut is, in seconds. Only needed when the
91
115
  length can't be read off the file itself
92
116
  --note "<text>" How to steer one redo (retry-frame, retry-clip)
93
- --node <nodeId> Which scene-frames node holds the still (retry-frame).
117
+ --node <nodeId> Which scene-frames node holds the still (retry-frame;
118
+ admins pass it, beta testers leave it off).
94
119
  Which video step holds the clip, when a scene has one on
95
120
  more than one step (retry-clip, revoice)
96
121
  --scene <n> Which scene to redo. One scene only, a whole number
97
122
  (retry-frame, retry-clip, revoice)
98
123
  --all Every finished clip that kept the video model's voice
99
124
  (revoice)
125
+ --voice-first Record the scene's line in the character's own voice,
126
+ then make the clip to match it (retry-clip). For a clip
127
+ whose words came out wrong
100
128
  --approve-stale-cut Deliver the cut you uploaded even though a clip changed
101
129
  after you uploaded it (approve). Without it, approving
102
130
  stops and names the scenes that changed
@@ -108,11 +136,41 @@ Options:
108
136
  --clear win over the same name in the file
109
137
  --rejected-draft Print the storyboard draft the system turned down
110
138
  (status). It was thrown away — a record only
139
+ --script <file> Text file of what the ad says (start)
140
+ --conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
141
+ --style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
142
+ --direction "<note>" Note for the whole ad (start)
143
+ --voice-path <path> How the voices are made (start)
144
+ --video-model <id> Which video model films this ad, instead of the kind of
145
+ ad's own default (start, with --conceit)
146
+ --voice <mode> voice-first (the default): the lines are recorded
147
+ first. native: the video model speaks the lines
148
+ (start, with --conceit)
149
+ --music Put a music bed under the ad (start). An ad has no music
150
+ unless you ask for it here
151
+ --no-music Leave the music bed off (start), which it already is
152
+ --review-storyboard Stop at the storyboard so you can approve it yourself
153
+ (start, with --conceit). By default the app approves its
154
+ own storyboard when its checks pass
155
+ --director Turn the trial director on for this ad (start, with
156
+ --conceit). Admins only. To switch it on or off later:
157
+ exodus video director <runId> on|off|follow, where
158
+ follow goes back to the brand's own setting
159
+ --wait Wait until the run needs you (start): at the
160
+ storyboard, or once every piece is made
111
161
  --json Machine-readable output
112
162
  --help, -h Print this help
113
163
 
114
- Video is admin-only. If every command here answers "video isn't enabled for
115
- this key", your dashboard user needs the admin role on this brand.
164
+ Who can use what. Admins get every command here. Beta testers with video
165
+ switched on get these, on their own brands: start (--script, --conceit,
166
+ --style, --direction, --music, --review-storyboard), status, storyboard,
167
+ approve (the storyboard), voices, retry-frame --scene <n> (redo one scene's
168
+ picture while the storyboard waits), pull (the finished video, each scene's
169
+ clip and voice, and the music) and upload. These stay admin-only:
170
+ start --video-model / --voice / --voice-path / --director, director,
171
+ status --rejected-draft, retry-clip and revoice.
172
+ If a command answers "video isn't enabled for this key", the run id is wrong,
173
+ the command is admin-only, or video isn't switched on for your dashboard user.
116
174
 
117
175
  Examples:
118
176
  exodus workflow list
@@ -122,17 +180,21 @@ Examples:
122
180
  exodus video approve run_123
123
181
  exodus video retry-frame run_123 --node frames-1 --scene 2
124
182
  exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
183
+ exodus video retry-clip run_123 --scene 3 --voice-first
125
184
  exodus video revoice run_123 --all
126
185
  exodus video voices run_123
127
186
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
128
187
  exodus video voices run_123 --from voices.json
129
188
  exodus video pull run_123 --out ./ad-run_123
130
189
  exodus video upload run_123 --file ./cut.mp4
190
+ exodus video director run_123 on
131
191
  `.trim();
132
192
  const SHOWS_PATH = "/api/v2/shows";
133
193
  const RUNS_PATH = "/api/v2/video/runs";
134
- const RUN_PATH = "/api/v2/workflow";
135
- const ITEMS_PATH = "/api/v2/workflow/items";
194
+ const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
195
+ const DIRECTOR_PATH = "/api/v2/video/director";
196
+ export const RUN_PATH = "/api/v2/workflow";
197
+ export const ITEMS_PATH = "/api/v2/workflow/items";
136
198
  const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
137
199
  const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
138
200
  "with the next server update.";
@@ -141,12 +203,15 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
141
203
  const APPROVE_PATH = "/api/v2/workflow/approve";
142
204
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
143
205
  const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
206
+ const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
207
+ const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
208
+ "was spent. It arrives with the next server update.";
144
209
  const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
145
210
  "was spent. It arrives with the next server update.";
146
- const FINAL_PATH = "/api/v2/video/final";
211
+ export const FINAL_PATH = "/api/v2/video/final";
147
212
  export const VOICES_PATH = "/api/v2/video/voices";
148
- const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
149
- const ASSETS_PATH = "/api/v2/workflows/assets";
213
+ export const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
214
+ export const ASSETS_PATH = "/api/v2/workflows/assets";
150
215
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
151
216
  return rest;
152
217
  }
@@ -155,7 +220,7 @@ export function qcWithoutJudgeWording(qc) {
155
220
  return qc;
156
221
  return {
157
222
  ...qc,
158
- takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
223
+ takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
159
224
  };
160
225
  }
161
226
  export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
@@ -184,6 +249,9 @@ export function renderQcTakeHistory(takes) {
184
249
  });
185
250
  return lines;
186
251
  }
252
+ function redoLine(lastRedo) {
253
+ return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
254
+ }
187
255
  function printDisplacedHistory(lines, lastRedo) {
188
256
  const history = lastRedo.displacedHistory;
189
257
  if (!history)
@@ -248,7 +316,9 @@ export const defaultDeps = {
248
316
  dashboardUrl: getDashboardUrl(),
249
317
  };
250
318
  export const VIDEO_NOT_FOUND_MESSAGE = "video isn't enabled for this key\n" +
251
- "Either your dashboard user needs the admin role on this brand (video is admin-only), or that run id doesn't exist here. Check the id, then ask Brad about the role.";
319
+ "One of three things: that run id doesn't exist on this brand, this command is for admins only " +
320
+ "(director, status --rejected-draft, retry-clip, revoice), or video isn't " +
321
+ "switched on for your dashboard user on this brand. Check the id first, then ask Brad.";
252
322
  export function videoApiError(res) {
253
323
  const behind = missingRouteLine(res, "exodus video");
254
324
  if (behind)
@@ -257,6 +327,9 @@ export function videoApiError(res) {
257
327
  return VIDEO_NOT_FOUND_MESSAGE;
258
328
  return formatApiError(res);
259
329
  }
330
+ function adminOnlyResult(line, json) {
331
+ return { code: 1, lines: json ? [JSON.stringify({ ok: false, error: line })] : [line] };
332
+ }
260
333
  function errorResult(res, json) {
261
334
  return {
262
335
  code: 1,
@@ -279,6 +352,14 @@ function parkedAtFinalWatch(run) {
279
352
  return (!parkedByBuilderCheckpoint(run) &&
280
353
  run.nodes.some((n) => n.kind === "video" && n.status === "done"));
281
354
  }
355
+ function heldReason(run) {
356
+ const verdict = run.storyboardAutoApproval;
357
+ if (verdict?.outcome !== "held")
358
+ return undefined;
359
+ const reason = verdict.reason?.trim() ||
360
+ "The app didn't approve this storyboard by itself because its checks found a problem.";
361
+ return `${reason} (checked ${new Date(verdict.at).toISOString()})`;
362
+ }
282
363
  export function classifyRun(run) {
283
364
  if (run.status === "failed")
284
365
  return { at: "failed", error: run.error };
@@ -298,6 +379,7 @@ export function classifyRun(run) {
298
379
  }
299
380
  const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
300
381
  if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
382
+ const held = heldReason(run);
301
383
  return {
302
384
  at: "storyboard-gate",
303
385
  nodeId: pausedNode.nodeId,
@@ -305,10 +387,31 @@ export function classifyRun(run) {
305
387
  ? { framesNodeId: pausedNode.nodeId }
306
388
  : {}),
307
389
  ...(isShowAd(run) ? { showAd: true } : {}),
390
+ ...(held ? { heldReason: held } : {}),
391
+ };
392
+ }
393
+ if (parkedAtFinalWatch(run)) {
394
+ const videoNodes = run.nodes.filter((n) => n.kind === "video");
395
+ const autoRedoScenes = videoNodes.flatMap((n) => n.autoRedoScenes ?? []);
396
+ const autoRedoReasons = videoNodes.flatMap((n) => n.autoRedoReasons ?? []);
397
+ if (autoRedoScenes.length > 0) {
398
+ return {
399
+ at: "running",
400
+ stage: "video",
401
+ autoRedoScenes,
402
+ ...(autoRedoReasons.length > 0 ? { autoRedoReasons } : {}),
403
+ };
404
+ }
405
+ const missingScenes = run.nodes
406
+ .filter((n) => n.kind === "video")
407
+ .flatMap((n) => n.missingScenes ?? []);
408
+ const framesNodeId = run.nodes.find((n) => n.kind === "scene-frames")?.nodeId;
409
+ return {
410
+ at: "final-watch",
411
+ ...(missingScenes.length > 0 ? { missingScenes } : {}),
412
+ ...(missingScenes.length > 0 && framesNodeId ? { framesNodeId } : {}),
308
413
  };
309
414
  }
310
- if (parkedAtFinalWatch(run))
311
- return { at: "final-watch" };
312
415
  return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
313
416
  }
314
417
  const active = run.nodes.find((n) => n.status === "running");
@@ -317,10 +420,22 @@ export function classifyRun(run) {
317
420
  export function hasAttachedCut(items) {
318
421
  return items.some((i) => i.itemKind === "final" && i.status === "done");
319
422
  }
423
+ export function scenesBeingRedone(run, items) {
424
+ const scenes = new Set(run.nodes.filter((n) => n.kind === "video").flatMap((n) => n.autoRedoScenes ?? []));
425
+ for (const i of items) {
426
+ if (i.itemKind === "clip" &&
427
+ i.status === "running" &&
428
+ (i.attempt ?? 1) > 1 &&
429
+ i.staleClaim !== true) {
430
+ scenes.add(i.sceneIndex);
431
+ }
432
+ }
433
+ return [...scenes].sort((a, b) => a - b);
434
+ }
320
435
  export function resolveStop(stop, cutAttached) {
321
436
  if (stop.at !== "final-watch")
322
437
  return stop;
323
- return { at: "final-watch", cutAttached };
438
+ return { ...stop, cutAttached };
324
439
  }
325
440
  export async function resolveStopAtPark(stop, runId, deps) {
326
441
  if (stop.at !== "final-watch")
@@ -359,15 +474,38 @@ export function isShowAd(run) {
359
474
  return run.moduleOwned === true;
360
475
  }
361
476
  export function reviewUrl(dashboardUrl, run) {
362
- if (isShowAd(run))
363
- return `${dashboardUrl}/video?ad=${run._id}`;
364
477
  if (run.workflowId)
365
478
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
366
479
  return `${dashboardUrl}/runs/${run._id}`;
367
480
  }
481
+ function scenesPhrase(scenes) {
482
+ if (scenes.length === 1)
483
+ return `scene ${scenes[0]}`;
484
+ return `scenes ${scenes.slice(0, -1).join(", ")} and ${scenes[scenes.length - 1]}`;
485
+ }
486
+ function missingSceneLines(missing, framesNodeId, runId, runUrl) {
487
+ const scenes = missing.map((m) => m.sceneIndex);
488
+ const lines = [
489
+ `Parked: ${scenesPhrase(scenes)} ${scenes.length === 1 ? "has" : "have"} no clip yet, so the ad isn't complete.`,
490
+ ];
491
+ for (const { sceneIndex, pictureRefused } of missing) {
492
+ if (!pictureRefused) {
493
+ lines.push(`Redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
494
+ continue;
495
+ }
496
+ lines.push(`Scene ${sceneIndex}'s picture was refused by the video service as too close to an existing character, so redraw it before redoing the clip.`);
497
+ lines.push(framesNodeId
498
+ ? `Redraw frame ${sceneIndex}: exodus video retry-frame ${runId} --node ${framesNodeId} --scene ${sceneIndex} --note "<what to change>"`
499
+ : `Redraw frame ${sceneIndex}: on the run page, ${runUrl}`);
500
+ lines.push(`Then redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
501
+ }
502
+ lines.push("The ad can't be approved until every scene has a clip.", `Watch it here: ${runUrl}`);
503
+ return lines;
504
+ }
368
505
  export function stopLines(stop, runId, runUrl) {
369
506
  if (stop.at === "storyboard-gate") {
370
507
  const lines = [
508
+ ...(stop.heldReason ? [stop.heldReason] : []),
371
509
  "Parked: the storyboard is waiting for your yes.",
372
510
  `Read it: exodus video storyboard ${runId}`,
373
511
  `Approve it: exodus video approve ${runId}`,
@@ -381,6 +519,9 @@ export function stopLines(stop, runId, runUrl) {
381
519
  return lines;
382
520
  }
383
521
  if (stop.at === "final-watch") {
522
+ if (stop.missingScenes?.length) {
523
+ return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
524
+ }
384
525
  if (stop.cutAttached === null) {
385
526
  return [
386
527
  "Parked: every piece is made.",
@@ -436,8 +577,28 @@ export function stopLines(stop, runId, runUrl) {
436
577
  `Open it: ${runUrl}`,
437
578
  ];
438
579
  }
580
+ if (stop.autoRedoScenes?.length)
581
+ return [autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)];
439
582
  return [`Working: ${stageWord(stop.stage)}.`];
440
583
  }
584
+ function autoRedoLine(scenes, reasons = []) {
585
+ const reasonOf = new Map(reasons.map((r) => [r.sceneIndex, r.reason]));
586
+ const groups = new Map();
587
+ for (const scene of [...scenes].sort((a, b) => a - b)) {
588
+ const reason = reasonOf.get(scene);
589
+ groups.set(reason, [...(groups.get(reason) ?? []), scene]);
590
+ }
591
+ const one = scenes.length === 1;
592
+ const clauses = [...groups].map(([reason, group], i) => {
593
+ const own = i === 0 ? ` on ${group.length === 1 ? "its" : "their"} own` : "";
594
+ return `${scenesPhrase(group)}${own}${reason ? ` because ${reason}` : ""}`;
595
+ });
596
+ const listed = clauses.length === 1
597
+ ? clauses[0]
598
+ : `${clauses.slice(0, -1).join(", ")}, and ${clauses[clauses.length - 1]}`;
599
+ return (`Working: the app is redoing ${listed}. ` +
600
+ `The ad isn't ready to watch until ${one ? "it lands" : "they land"}.`);
601
+ }
441
602
  export function failedStoryboardNode(run) {
442
603
  return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
443
604
  }
@@ -462,7 +623,13 @@ export function planFailureLines(node, runId) {
462
623
  }
463
624
  return lines;
464
625
  }
626
+ export const MUSIC_HEARD_CODE = "music-heard";
465
627
  export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
628
+ export function musicBedState(run) {
629
+ if (run.musicBed === undefined)
630
+ return "unknown";
631
+ return run.musicBed ? "on" : "off";
632
+ }
466
633
  function isPullSceneIndex(sceneIndex) {
467
634
  return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
468
635
  }
@@ -504,6 +671,10 @@ function clipFromArtifact(sceneIndex, artifact) {
504
671
  revoiced: artifact.revoiced === true,
505
672
  speechTrimmed: artifact.speechTrimmed === true,
506
673
  rawStorageId: artifact.rawStorageId ?? null,
674
+ voiceMode: artifact.voiceMode ?? null,
675
+ voiceFirstBlockedBy: artifact.voiceFirstBlockedBy ?? null,
676
+ voicePitchHz: artifact.voicePitchHz ?? null,
677
+ voicePitchMethod: artifact.voicePitchMethod ?? null,
507
678
  };
508
679
  }
509
680
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -655,14 +826,36 @@ export function planPull(run, items, opts) {
655
826
  }
656
827
  }
657
828
  let reference = null;
829
+ let productSheet = null;
830
+ let setPlate = null;
831
+ const filledByStage = new Map();
658
832
  for (const artifact of outputsOfNodeKind(run, "reference")) {
659
833
  if (artifact.type !== "image" || !artifact.imageUrl)
660
834
  continue;
661
- reference = {
662
- file: `reference.${extFor(artifact.imageUrl, "image")}`,
663
- url: artifact.imageUrl,
664
- };
665
- }
835
+ if (artifact.anchor === "filled-plate") {
836
+ const holds = artifact.holds ?? [];
837
+ const lid = artifact.lid;
838
+ filledByStage.set(JSON.stringify([holds, lid ?? null]), { holds, url: artifact.imageUrl, ...(lid ? { lid } : {}) });
839
+ continue;
840
+ }
841
+ const stem = artifact.anchor ?? "reference";
842
+ const download = { file: `${stem}.${extFor(artifact.imageUrl, "image")}`, url: artifact.imageUrl };
843
+ if (artifact.anchor === "product-sheet")
844
+ productSheet = download;
845
+ else if (artifact.anchor === "set-plate")
846
+ setPlate = download;
847
+ else if (!artifact.anchor)
848
+ reference = download;
849
+ }
850
+ const stageKeys = [...new Set([...filledByStage.values()].map((plate) => JSON.stringify(plate.holds)))];
851
+ const filledPlates = [...filledByStage.values()].map((plate) => ({
852
+ holds: plate.holds,
853
+ ...(plate.lid ? { lid: plate.lid } : {}),
854
+ download: {
855
+ file: `filled-plate-${stageKeys.indexOf(JSON.stringify(plate.holds)) + 1}${plate.lid ? "-lid-closed" : ""}.${extFor(plate.url, "image")}`,
856
+ url: plate.url,
857
+ },
858
+ }));
666
859
  const keyframeByScene = new Map();
667
860
  for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
668
861
  if (artifact.type !== "frames")
@@ -765,6 +958,9 @@ export function planPull(run, items, opts) {
765
958
  continue;
766
959
  keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
767
960
  }
961
+ const pendingRedo = scenesBeingRedone(run, items);
962
+ for (const sceneIndex of pendingRedo)
963
+ clipByScene.delete(sceneIndex);
768
964
  const sceneIndexes = [
769
965
  ...new Set([
770
966
  ...clipByScene.keys(),
@@ -813,6 +1009,10 @@ export function planPull(run, items, opts) {
813
1009
  revoiced: clip ? clip.revoiced : null,
814
1010
  speechTrimmed: clip ? clip.speechTrimmed : null,
815
1011
  rawStorageId: clip ? clip.rawStorageId : null,
1012
+ voiceMode: clip ? clip.voiceMode : null,
1013
+ voiceFirstBlockedBy: clip ? clip.voiceFirstBlockedBy : null,
1014
+ voicePitchHz: clip ? clip.voicePitchHz : null,
1015
+ voicePitchMethod: clip ? clip.voicePitchMethod : null,
816
1016
  clipStatus: item?.status ?? "missing",
817
1017
  error: item?.error ?? null,
818
1018
  flagged: item?.flagged === true,
@@ -823,6 +1023,9 @@ export function planPull(run, items, opts) {
823
1023
  const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
824
1024
  const downloads = [
825
1025
  ...(reference ? [reference] : []),
1026
+ ...(productSheet ? [productSheet] : []),
1027
+ ...(setPlate ? [setPlate] : []),
1028
+ ...filledPlates.map((p) => p.download),
826
1029
  ...keyframeByScene.values(),
827
1030
  ...[...voiceByScene.values()].map((v) => v.download),
828
1031
  ...(narrationDownload ? [narrationDownload] : []),
@@ -839,7 +1042,15 @@ export function planPull(run, items, opts) {
839
1042
  dashboardUrl: reviewUrl(opts.dashboardUrl, run),
840
1043
  storyboard,
841
1044
  reference: reference?.file ?? null,
1045
+ productSheet: productSheet?.file ?? null,
1046
+ setPlate: setPlate?.file ?? null,
1047
+ filledPlates: filledPlates.map((p) => ({ file: p.download.file, holds: p.holds, ...(p.lid ? { lid: p.lid } : {}) })),
842
1048
  music: music?.file ?? null,
1049
+ musicBed: musicBedState(run),
1050
+ musicHeardScenes: scenes
1051
+ .filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
1052
+ .map((scene) => scene.sceneIndex),
1053
+ pendingRedo,
843
1054
  cast,
844
1055
  narration: narrationDownload
845
1056
  ? { file: narrationDownload.file, timing: "narration.json" }
@@ -849,6 +1060,14 @@ export function planPull(run, items, opts) {
849
1060
  ...(run.provenance?.format || run.provenance?.voice
850
1061
  ? { provenance: run.provenance }
851
1062
  : {}),
1063
+ ...(run.videoChoice
1064
+ ? {
1065
+ videoChoice: {
1066
+ videoModel: run.videoChoice.videoModel,
1067
+ voiceMode: run.videoChoice.voiceMode,
1068
+ },
1069
+ }
1070
+ : {}),
852
1071
  },
853
1072
  };
854
1073
  }
@@ -858,6 +1077,14 @@ export function markPullFailure(manifest, failure) {
858
1077
  manifest.storyboard = null;
859
1078
  if (manifest.reference === failure.file)
860
1079
  manifest.reference = null;
1080
+ if (manifest.productSheet === failure.file)
1081
+ manifest.productSheet = null;
1082
+ if (manifest.setPlate === failure.file)
1083
+ manifest.setPlate = null;
1084
+ for (const plate of manifest.filledPlates) {
1085
+ if (plate.file === failure.file)
1086
+ plate.file = null;
1087
+ }
861
1088
  if (manifest.music === failure.file)
862
1089
  manifest.music = null;
863
1090
  for (const ref of manifest.cast) {
@@ -874,6 +1101,10 @@ export function markPullFailure(manifest, failure) {
874
1101
  scene.revoiced = null;
875
1102
  scene.speechTrimmed = null;
876
1103
  scene.rawStorageId = null;
1104
+ scene.voiceMode = null;
1105
+ scene.voiceFirstBlockedBy = null;
1106
+ scene.voicePitchHz = null;
1107
+ scene.voicePitchMethod = null;
877
1108
  }
878
1109
  if (scene.voice === failure.file)
879
1110
  scene.voice = null;
@@ -979,7 +1210,7 @@ export async function startFlow(opts, deps) {
979
1210
  showId: opts.showId,
980
1211
  script,
981
1212
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
982
- ...(opts.music === false ? { music: false } : {}),
1213
+ ...(opts.music === undefined ? {} : { music: opts.music }),
983
1214
  });
984
1215
  if (!res.ok)
985
1216
  return errorResult(res, opts.json);
@@ -1008,13 +1239,329 @@ export async function startFlow(opts, deps) {
1008
1239
  lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
1009
1240
  };
1010
1241
  }
1242
+ function nonBlank(value) {
1243
+ if (value === undefined || value.trim().length === 0)
1244
+ return undefined;
1245
+ return value;
1246
+ }
1247
+ function isVoiceMode(value) {
1248
+ return VOICE_MODES.some((mode) => mode === value);
1249
+ }
1250
+ function isConceit(value) {
1251
+ return CONCEIT_KEYS.some((key) => key === value);
1252
+ }
1253
+ export function planMusicChoice(occurrences) {
1254
+ let music;
1255
+ for (const { flag, value } of occurrences) {
1256
+ if (flag === "no-music") {
1257
+ music = false;
1258
+ continue;
1259
+ }
1260
+ if (flag !== "music")
1261
+ continue;
1262
+ if (value === undefined || value.startsWith("--")) {
1263
+ music = true;
1264
+ continue;
1265
+ }
1266
+ const word = value.trim().toLowerCase();
1267
+ const literal = word === "true" ? true : word === "false" ? false : undefined;
1268
+ if (literal === undefined) {
1269
+ return {
1270
+ ok: false,
1271
+ line: `video start --music takes true or false, or no value at all (got "${value}").`,
1272
+ };
1273
+ }
1274
+ music = literal;
1275
+ }
1276
+ return { ok: true, music };
1277
+ }
1278
+ function planValueFlag(occurrences, name) {
1279
+ let value;
1280
+ for (const occurrence of occurrences) {
1281
+ if (occurrence.flag !== name)
1282
+ continue;
1283
+ const given = occurrence.value;
1284
+ if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
1285
+ return { ok: false, line: `video start --${name} needs a value.` };
1286
+ }
1287
+ value = given.trim();
1288
+ }
1289
+ return { ok: true, value };
1290
+ }
1291
+ export function planVideoStart(flags, occurrences) {
1292
+ const showId = nonBlank(flagString(flags, "show"));
1293
+ const scriptFile = nonBlank(flagString(flags, "script"));
1294
+ const conceit = nonBlank(flagString(flags, "conceit"));
1295
+ const style = nonBlank(flagString(flags, "style"));
1296
+ if (showId && (conceit || style)) {
1297
+ return {
1298
+ kind: "usage",
1299
+ line: "Pass either --show <id>, or --conceit and --style. Not both.",
1300
+ };
1301
+ }
1302
+ if (!scriptFile) {
1303
+ return {
1304
+ kind: "usage",
1305
+ line: "video start needs --script <file>, a text file of what the ad says.",
1306
+ };
1307
+ }
1308
+ const musicChoice = planMusicChoice(occurrences);
1309
+ if (!musicChoice.ok)
1310
+ return { kind: "usage", line: musicChoice.line };
1311
+ const videoModelFlag = planValueFlag(occurrences, "video-model");
1312
+ if (!videoModelFlag.ok)
1313
+ return { kind: "usage", line: videoModelFlag.line };
1314
+ const voiceFlag = planValueFlag(occurrences, "voice");
1315
+ if (!voiceFlag.ok)
1316
+ return { kind: "usage", line: voiceFlag.line };
1317
+ const videoModel = videoModelFlag.value;
1318
+ const voiceMode = voiceFlag.value;
1319
+ const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
1320
+ if (showId && reviewStoryboard) {
1321
+ return {
1322
+ kind: "usage",
1323
+ line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
1324
+ };
1325
+ }
1326
+ const directorFlags = occurrences.filter((o) => o.flag === "director");
1327
+ if (directorFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1328
+ return {
1329
+ kind: "usage",
1330
+ line: "--director takes no value. Leave it off to start without the director.",
1331
+ };
1332
+ }
1333
+ const director = directorFlags.length > 0;
1334
+ if (showId && director) {
1335
+ return {
1336
+ kind: "usage",
1337
+ line: "--director works with --conceit, not --show.",
1338
+ };
1339
+ }
1340
+ if (showId && (videoModel || voiceMode)) {
1341
+ return {
1342
+ kind: "usage",
1343
+ line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
1344
+ };
1345
+ }
1346
+ if (voiceMode && !isVoiceMode(voiceMode)) {
1347
+ return {
1348
+ kind: "usage",
1349
+ line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
1350
+ };
1351
+ }
1352
+ if (showId) {
1353
+ return {
1354
+ kind: "show",
1355
+ opts: {
1356
+ showId,
1357
+ scriptFile,
1358
+ voicePath: flagString(flags, "voice-path"),
1359
+ music: musicChoice.music,
1360
+ wait: flags["wait"] === true,
1361
+ json: flags["json"] === true,
1362
+ },
1363
+ };
1364
+ }
1365
+ if (!conceit) {
1366
+ return {
1367
+ kind: "usage",
1368
+ line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
1369
+ };
1370
+ }
1371
+ if (!isConceit(conceit)) {
1372
+ return {
1373
+ kind: "usage",
1374
+ line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
1375
+ };
1376
+ }
1377
+ if (!style) {
1378
+ return {
1379
+ kind: "usage",
1380
+ line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
1381
+ };
1382
+ }
1383
+ const direction = nonBlank(flagString(flags, "direction"));
1384
+ const voicePath = nonBlank(flagString(flags, "voice-path"));
1385
+ const opts = {
1386
+ scriptFile,
1387
+ conceit,
1388
+ style,
1389
+ wait: flags["wait"] === true,
1390
+ json: flags["json"] === true,
1391
+ };
1392
+ if (direction)
1393
+ opts.direction = direction;
1394
+ if (voicePath)
1395
+ opts.voicePath = voicePath;
1396
+ if (videoModel)
1397
+ opts.videoModel = videoModel;
1398
+ if (voiceMode && isVoiceMode(voiceMode))
1399
+ opts.voiceMode = voiceMode;
1400
+ if (musicChoice.music !== undefined)
1401
+ opts.music = musicChoice.music;
1402
+ if (reviewStoryboard)
1403
+ opts.reviewStoryboard = true;
1404
+ if (director)
1405
+ opts.director = true;
1406
+ return { kind: "script", opts };
1407
+ }
1408
+ function isRecord(value) {
1409
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1410
+ }
1411
+ function isResolvedCard(value) {
1412
+ if (!isRecord(value))
1413
+ return false;
1414
+ if (!isRecord(value.conceit) || !isRecord(value.style))
1415
+ return false;
1416
+ if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
1417
+ return false;
1418
+ if (typeof value.family !== "string")
1419
+ return false;
1420
+ if (!("wrapper" in value))
1421
+ return false;
1422
+ if (value.wrapper !== null && typeof value.wrapper !== "string")
1423
+ return false;
1424
+ if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
1425
+ return false;
1426
+ return true;
1427
+ }
1428
+ function startedVideoChoice(value) {
1429
+ if (!isRecord(value))
1430
+ return null;
1431
+ if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
1432
+ return null;
1433
+ return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1434
+ }
1435
+ function scriptStartedLines(runId, url, card, video, director) {
1436
+ return [
1437
+ `Started ad run ${runId}`,
1438
+ ...(card
1439
+ ? [
1440
+ `Conceit: ${card.conceit.label}`,
1441
+ `Family: ${card.family}`,
1442
+ `Wrapper: ${card.wrapper ?? ""}`,
1443
+ `Style: ${card.style.label}`,
1444
+ ]
1445
+ : ["The server did not include a resolved card."]),
1446
+ ...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
1447
+ ...(director ? ["Director: on for this ad"] : []),
1448
+ `Watch it: ${url}`,
1449
+ ];
1450
+ }
1451
+ function waitJsonWithCard(waited, card, video) {
1452
+ const first = waited.lines[0];
1453
+ if (typeof first !== "string")
1454
+ return waited;
1455
+ let parsed;
1456
+ try {
1457
+ parsed = JSON.parse(first);
1458
+ }
1459
+ catch {
1460
+ return waited;
1461
+ }
1462
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1463
+ return waited;
1464
+ return {
1465
+ code: waited.code,
1466
+ lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1467
+ };
1468
+ }
1469
+ export async function startScriptFlow(opts, deps) {
1470
+ let script;
1471
+ try {
1472
+ script = deps.readFile(opts.scriptFile);
1473
+ }
1474
+ catch (e) {
1475
+ return {
1476
+ code: 1,
1477
+ lines: [
1478
+ `Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
1479
+ ],
1480
+ };
1481
+ }
1482
+ if (script.trim().length === 0) {
1483
+ return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
1484
+ }
1485
+ const res = await deps.post(SCRIPT_RUNS_PATH, {
1486
+ script,
1487
+ conceit: opts.conceit,
1488
+ style: opts.style,
1489
+ ...(opts.direction ? { direction: opts.direction } : {}),
1490
+ ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1491
+ ...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
1492
+ ...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
1493
+ ...(opts.music === true ? { music: true } : {}),
1494
+ ...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
1495
+ ...(opts.director ? { director: true } : {}),
1496
+ });
1497
+ if (!res.ok)
1498
+ return errorResult(res, opts.json);
1499
+ const customerStart = customerAnswer(res.data);
1500
+ if (customerStart)
1501
+ return customerStartedResult(customerStart, opts, deps);
1502
+ const started = res.data;
1503
+ if (!started.runId) {
1504
+ return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
1505
+ }
1506
+ const runId = started.runId;
1507
+ const url = typeof started.url === "string" ? started.url : "";
1508
+ const card = isResolvedCard(started.card) ? started.card : null;
1509
+ const video = startedVideoChoice(started.videoChoice);
1510
+ if (!opts.wait) {
1511
+ return {
1512
+ code: 0,
1513
+ lines: opts.json
1514
+ ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1515
+ : [
1516
+ ...scriptStartedLines(runId, url, card, video, opts.director === true),
1517
+ "",
1518
+ ...(opts.reviewStoryboard
1519
+ ? ["Wait for the storyboard here instead: exodus video start … --wait"]
1520
+ : [
1521
+ "The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
1522
+ "Wait here until it needs you: exodus video start … --wait",
1523
+ ]),
1524
+ `Or check in whenever: exodus video status ${runId}`,
1525
+ ],
1526
+ };
1527
+ }
1528
+ const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1529
+ if (opts.json)
1530
+ return waitJsonWithCard(waited, card, video);
1531
+ return {
1532
+ code: waited.code,
1533
+ lines: [...scriptStartedLines(runId, url, card, video, opts.director === true), "", ...waited.lines],
1534
+ };
1535
+ }
1536
+ async function customerStartedResult(started, opts, deps) {
1537
+ const { runId, dashboardUrl } = started;
1538
+ const head = [`Started video ${runId}`, `Watch it: ${dashboardUrl}`];
1539
+ if (opts.wait) {
1540
+ const waited = await waitFlow(runId, { json: opts.json, url: dashboardUrl }, deps);
1541
+ return opts.json ? waited : { code: waited.code, lines: [...head, "", ...waited.lines] };
1542
+ }
1543
+ if (opts.json)
1544
+ return { code: 0, lines: [JSON.stringify(started)] };
1545
+ return {
1546
+ code: 0,
1547
+ lines: [
1548
+ ...head,
1549
+ "",
1550
+ opts.reviewStoryboard
1551
+ ? "It stops at the storyboard for you to approve."
1552
+ : "The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
1553
+ `Check in whenever: exodus video status ${runId}`,
1554
+ ],
1555
+ };
1556
+ }
1011
1557
  const POLL_INTERVAL_MS = 5000;
1012
1558
  const MAX_POLLS = 720;
1013
1559
  export async function waitFlow(runId, opts, deps) {
1014
1560
  const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
1015
1561
  const maxPolls = opts.maxPolls ?? MAX_POLLS;
1016
1562
  const lines = [];
1017
- let lastStage = null;
1563
+ let lastProgress = null;
1564
+ let redoSeen = false;
1018
1565
  for (let poll = 0; poll < maxPolls; poll++) {
1019
1566
  if (poll > 0)
1020
1567
  await deps.sleep(interval);
@@ -1023,17 +1570,57 @@ export async function waitFlow(runId, opts, deps) {
1023
1570
  const failed = errorResult(res, opts.json);
1024
1571
  return { code: failed.code, lines: [...lines, ...failed.lines] };
1025
1572
  }
1573
+ const customer = customerAnswer(res.data);
1574
+ if (customer) {
1575
+ const page = customerRunPage(deps.dashboardUrl, runId);
1576
+ if (customerStageIsWorking(customer.stage)) {
1577
+ const progress = customerStageLines(customer, page)[0];
1578
+ if (progress !== lastProgress) {
1579
+ lastProgress = progress;
1580
+ if (!opts.json)
1581
+ lines.push(progress);
1582
+ }
1583
+ continue;
1584
+ }
1585
+ return {
1586
+ code: customer.stage === "failed" ? 1 : 0,
1587
+ lines: opts.json
1588
+ ? [JSON.stringify(customer)]
1589
+ : [...lines, ...customerStageLines(customer, page), `Watch it on the dashboard: ${page}`],
1590
+ };
1591
+ }
1026
1592
  const run = asVideoRun(res.data);
1027
1593
  const stop = classifyRun(run);
1028
1594
  if (stop.at === "running") {
1029
- if (stop.stage !== lastStage) {
1030
- lastStage = stop.stage;
1595
+ const progress = stop.autoRedoScenes?.length
1596
+ ? autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)
1597
+ : `Working: ${stageWord(stop.stage)}…`;
1598
+ if (progress !== lastProgress) {
1599
+ lastProgress = progress;
1031
1600
  if (!opts.json)
1032
- lines.push(`Working: ${stageWord(stop.stage)}…`);
1601
+ lines.push(progress);
1033
1602
  }
1034
1603
  continue;
1035
1604
  }
1036
- const resolved = await resolveStopAtPark(stop, runId, deps);
1605
+ let items = null;
1606
+ if (stop.at === "final-watch") {
1607
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1608
+ items = itemsRes.ok ? (itemsRes.data.items ?? []) : null;
1609
+ if (items === null && redoSeen)
1610
+ continue;
1611
+ const redoing = items ? scenesBeingRedone(run, items) : [];
1612
+ redoSeen = redoing.length > 0;
1613
+ if (redoSeen) {
1614
+ const progress = `Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`;
1615
+ if (progress !== lastProgress) {
1616
+ lastProgress = progress;
1617
+ if (!opts.json)
1618
+ lines.push(progress);
1619
+ }
1620
+ continue;
1621
+ }
1622
+ }
1623
+ const resolved = resolveStop(stop, items ? hasAttachedCut(items) : null);
1037
1624
  if (opts.json) {
1038
1625
  return {
1039
1626
  code: stop.at === "failed" ? 1 : 0,
@@ -1076,6 +1663,13 @@ export async function statusFlow(runId, json, deps) {
1076
1663
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1077
1664
  if (!runRes.ok)
1078
1665
  return errorResult(runRes, json);
1666
+ const customer = customerAnswer(runRes.data);
1667
+ if (customer) {
1668
+ return {
1669
+ code: 0,
1670
+ lines: json ? [JSON.stringify(customer)] : customerStatusLines(customer, deps.dashboardUrl),
1671
+ };
1672
+ }
1079
1673
  const run = asVideoRun(runRes.data);
1080
1674
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1081
1675
  if (!itemsRes.ok)
@@ -1102,18 +1696,32 @@ export async function statusFlow(runId, json, deps) {
1102
1696
  hasFinal,
1103
1697
  warnings,
1104
1698
  guidance,
1699
+ ...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
1105
1700
  ...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
1106
1701
  ...(failedStoryboard?.planFailure
1107
1702
  ? { storyboardNodeId: failedStoryboard.nodeId }
1108
1703
  : {}),
1109
1704
  ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1705
+ ...(run.director ? { director: run.director } : {}),
1110
1706
  }),
1111
1707
  ],
1112
1708
  };
1113
1709
  }
1114
1710
  const revoicedByScene = new Set();
1711
+ const voiceFirstByScene = new Set();
1712
+ const voiceFirstBlockedByScene = new Map();
1115
1713
  const timedOnOriginalVoice = new Set();
1116
1714
  for (const artifact of outputsOfNodeKind(run, "video")) {
1715
+ if (artifact.type === "video" &&
1716
+ artifact.voiceMode === "voice-first" &&
1717
+ typeof artifact.sceneIndex === "number") {
1718
+ voiceFirstByScene.add(artifact.sceneIndex);
1719
+ }
1720
+ if (artifact.type === "video" &&
1721
+ artifact.voiceFirstBlockedBy?.length &&
1722
+ typeof artifact.sceneIndex === "number") {
1723
+ voiceFirstBlockedByScene.set(artifact.sceneIndex, artifact.voiceFirstBlockedBy);
1724
+ }
1117
1725
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
1118
1726
  revoicedByScene.add(artifact.sceneIndex);
1119
1727
  if ((artifact.words?.length ?? 0) > 0)
@@ -1144,6 +1752,7 @@ export async function statusFlow(runId, json, deps) {
1144
1752
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1145
1753
  ...planFailureLines(failedStoryboard, runId),
1146
1754
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
1755
+ ...(run.director ? ["", ...directorLines(run.director)] : []),
1147
1756
  ];
1148
1757
  if (byScene.size === 0) {
1149
1758
  lines.push("", "No scenes yet — this run hasn't made anything to look at.");
@@ -1154,7 +1763,7 @@ export async function statusFlow(runId, json, deps) {
1154
1763
  const row = byScene.get(sceneIndex);
1155
1764
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1156
1765
  if (row.clip?.lastRedo) {
1157
- lines.push(` clip: ${row.clip.lastRedo.label}`);
1766
+ lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
1158
1767
  printDisplacedHistory(lines, row.clip.lastRedo);
1159
1768
  }
1160
1769
  if (row.clip?.lastRedo || row.clip?.flagged) {
@@ -1163,7 +1772,7 @@ export async function statusFlow(runId, json, deps) {
1163
1772
  }
1164
1773
  }
1165
1774
  if (row.voiceover?.lastRedo) {
1166
- lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1775
+ lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
1167
1776
  printDisplacedHistory(lines, row.voiceover.lastRedo);
1168
1777
  }
1169
1778
  if (row.frame?.lastRedo)
@@ -1182,6 +1791,12 @@ export async function statusFlow(runId, json, deps) {
1182
1791
  lines.push(` ${row.clip.error}`);
1183
1792
  if (revoicedByScene.has(sceneIndex))
1184
1793
  lines.push(" voice: cast voice applied");
1794
+ if (voiceFirstByScene.has(sceneIndex))
1795
+ lines.push(" clip: redone voice first");
1796
+ const blockedBy = voiceFirstBlockedByScene.get(sceneIndex);
1797
+ if (blockedBy) {
1798
+ lines.push(` clip: redone the usual way — ${cantSayNames(blockedBy)}`);
1799
+ }
1185
1800
  if (timedOnOriginalVoice.has(sceneIndex)) {
1186
1801
  lines.push(" word timings: measured on the original voice (close, not frame-exact)");
1187
1802
  }
@@ -1200,10 +1815,79 @@ export async function statusFlow(runId, json, deps) {
1200
1815
  : "Final cut: not uploaded yet.");
1201
1816
  return { code: 0, lines };
1202
1817
  }
1818
+ const DIRECTOR_STATUS_WORDS = {
1819
+ reviewing: "Watching this run",
1820
+ waiting: "Waiting for redos to finish",
1821
+ done: "Done: ready for your approval",
1822
+ stopped: "Stopped: see the last note",
1823
+ };
1824
+ const DIRECTOR_PHASE_WORDS = {
1825
+ storyboard: "Storyboard",
1826
+ "final-watch": "Final watch",
1827
+ };
1828
+ const DIRECTOR_LOG_LINES = 3;
1829
+ export function directorLines(director) {
1830
+ const switchWords = director.runSetting === null
1831
+ ? `following the brand's setting (${director.workspaceOn ? "on" : "off"})`
1832
+ : "switched for this ad";
1833
+ const log = director.log.slice(-DIRECTOR_LOG_LINES);
1834
+ return [
1835
+ `Director: ${director.on ? "on" : "off"}, ${switchWords}`,
1836
+ ...(director.on
1837
+ ? [` ${DIRECTOR_STATUS_WORDS[director.status ?? "reviewing"]}`]
1838
+ : []),
1839
+ ` Redos: ${director.redosUsed} of ${director.redoCap}. Spent: $${director.spendUsd.toFixed(2)} of $${director.spendCapUsd.toFixed(2)}`,
1840
+ ...(log.length > 0
1841
+ ? [
1842
+ " Latest notes:",
1843
+ ...log.map((entry) => ` ${DIRECTOR_PHASE_WORDS[entry.phase]}: ${entry.text}`),
1844
+ ]
1845
+ : []),
1846
+ ];
1847
+ }
1848
+ export function parseDirectorSwitch(word) {
1849
+ if (word === "on")
1850
+ return { on: true };
1851
+ if (word === "off")
1852
+ return { on: false };
1853
+ if (word === "follow")
1854
+ return { on: null };
1855
+ return null;
1856
+ }
1857
+ export async function directorFlow(runId, on, json, deps) {
1858
+ const res = await deps.post(DIRECTOR_PATH, { runId, on });
1859
+ const behind = missingRouteLine(res, "exodus video director");
1860
+ if (behind) {
1861
+ return {
1862
+ code: 1,
1863
+ lines: json ? [JSON.stringify({ ok: false, status: 404, error: behind })] : [behind],
1864
+ };
1865
+ }
1866
+ if (!res.ok)
1867
+ return errorResult(res, json);
1868
+ if (json)
1869
+ return { code: 0, lines: [JSON.stringify({ runId, on })] };
1870
+ const said = on === null
1871
+ ? `The director on ad run ${runId} now follows the brand's own setting.`
1872
+ : `The director is ${on ? "on" : "off"} for ad run ${runId}.`;
1873
+ return {
1874
+ code: 0,
1875
+ lines: [
1876
+ said,
1877
+ ...(on === true
1878
+ ? ["If the ad is already waiting at the storyboard or the final watch, a review starts now."]
1879
+ : []),
1880
+ `See what it is doing: exodus video status ${runId}`,
1881
+ ],
1882
+ };
1883
+ }
1203
1884
  export async function rejectedDraftFlow(runId, json, deps) {
1204
1885
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1205
1886
  if (!runRes.ok)
1206
1887
  return errorResult(runRes, json);
1888
+ if (customerAnswer(runRes.data)) {
1889
+ return adminOnlyResult("exodus video status --rejected-draft is for admins.", json);
1890
+ }
1207
1891
  const node = failedStoryboardNode(asVideoRun(runRes.data));
1208
1892
  if (!node) {
1209
1893
  const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
@@ -1243,6 +1927,10 @@ export async function storyboardFlow(runId, json, deps) {
1243
1927
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
1244
1928
  if (!res.ok)
1245
1929
  return errorResult(res, json);
1930
+ const board = customerAnswer(res.data);
1931
+ if (board) {
1932
+ return { code: 0, lines: json ? [JSON.stringify(board)] : customerStoryboardLines(board) };
1933
+ }
1246
1934
  const cards = res.data;
1247
1935
  if (json)
1248
1936
  return { code: 0, lines: [JSON.stringify(cards)] };
@@ -1381,14 +2069,34 @@ export async function flagFlow(runId, note, json, deps) {
1381
2069
  };
1382
2070
  }
1383
2071
  export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps) {
2072
+ if (nodeId === undefined) {
2073
+ const board = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
2074
+ if (!board.ok)
2075
+ return errorResult(board, json);
2076
+ if (!customerAnswer(board.data)) {
2077
+ return { code: 1, lines: ["video retry-frame needs --node <nodeId>."] };
2078
+ }
2079
+ }
1384
2080
  const res = await deps.post(SCENE_RETRY_PATH, {
1385
2081
  runId,
1386
- nodeId,
2082
+ ...(nodeId === undefined ? {} : { nodeId }),
1387
2083
  sceneIndex,
1388
2084
  ...(note ? { note } : {}),
1389
2085
  });
1390
2086
  if (!res.ok)
1391
2087
  return errorResult(res, json);
2088
+ if (customerAnswer(res.data)) {
2089
+ if (json)
2090
+ return { code: 0, lines: [JSON.stringify({ ok: true, runId, sceneIndex })] };
2091
+ return {
2092
+ code: 0,
2093
+ lines: [
2094
+ `Redrawing scene ${sceneIndex}'s picture. The other scenes stay as they are.`,
2095
+ ...(note ? ["Your note isn't used on this redo."] : []),
2096
+ `See it when it's done: exodus video storyboard ${runId}`,
2097
+ ],
2098
+ };
2099
+ }
1392
2100
  const triggerRunId = res.data.triggerRunId;
1393
2101
  if (json) {
1394
2102
  return {
@@ -1425,7 +2133,10 @@ export function planClipRedo(run, items, target) {
1425
2133
  const { row, uploadedCut } = found;
1426
2134
  const redoable = row.status === "failed" ||
1427
2135
  row.staleClaim === true ||
1428
- (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
2136
+ (row.status === "done" &&
2137
+ (row.flagged === true ||
2138
+ stop.at === "final-watch" ||
2139
+ (stop.at === "running" && stop.autoRedoScenes !== undefined)));
1429
2140
  if (!redoable) {
1430
2141
  if (row.status === "done") {
1431
2142
  return {
@@ -1482,10 +2193,14 @@ function findClipRow(run, items, target) {
1482
2193
  }
1483
2194
  return { row, uploadedCut };
1484
2195
  }
1485
- export async function retryClipFlow(runId, target, note, json, deps) {
2196
+ export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
1486
2197
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1487
2198
  if (!runRes.ok)
1488
2199
  return errorResult(runRes, json);
2200
+ if (customerAnswer(runRes.data)) {
2201
+ return adminOnlyResult("Redoing a finished clip is for admins. To redo a scene's picture while your storyboard " +
2202
+ `waits for you: exodus video retry-frame ${runId} --scene <n>`, json);
2203
+ }
1489
2204
  const run = asVideoRun(runRes.data);
1490
2205
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1491
2206
  if (!itemsRes.ok)
@@ -1498,12 +2213,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1498
2213
  lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1499
2214
  };
1500
2215
  }
1501
- const res = await deps.post(SCENE_RETRY_PATH, {
2216
+ const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
1502
2217
  runId,
1503
2218
  nodeId: plan.nodeId,
1504
2219
  sceneIndex: plan.sceneIndex,
1505
2220
  ...(note ? { note } : {}),
1506
2221
  });
2222
+ if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
2223
+ return {
2224
+ code: 1,
2225
+ lines: json
2226
+ ? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
2227
+ : [VOICE_FIRST_NOT_ON_THIS_SERVER],
2228
+ };
2229
+ }
1507
2230
  if (!res.ok)
1508
2231
  return errorResult(res, json);
1509
2232
  const triggerRunId = res.data.triggerRunId;
@@ -1522,6 +2245,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1522
2245
  reviewUrl: review,
1523
2246
  warnings: plan.warnings,
1524
2247
  ...(note ? { note } : {}),
2248
+ ...(voiceFirst ? { voiceMode: "voice-first" } : {}),
1525
2249
  }),
1526
2250
  ],
1527
2251
  };
@@ -1529,9 +2253,19 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1529
2253
  return {
1530
2254
  code: 0,
1531
2255
  lines: [
1532
- `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
2256
+ voiceFirst
2257
+ ? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
2258
+ "recorded in the character's own voice, then the clip is made to match it. " +
2259
+ "If the line has a made-up name the recorded voice can't say, the clip may be made " +
2260
+ `the usual way instead, and exodus video status ${runId} will say so.`
2261
+ : `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1533
2262
  `triggerRunId: ${triggerRunId ?? "-"}`,
1534
- "Every other scene stays as it is, and so do the pictures, the voices and the script.",
2263
+ ...(voiceFirst
2264
+ ? [
2265
+ "Every other scene stays as it is, and so do the pictures and the script.",
2266
+ "A speaking character with no voice yet gets one picked and saved for the whole run.",
2267
+ ]
2268
+ : ["Every other scene stays as it is, and so do the pictures, the voices and the script."]),
1535
2269
  ...(note
1536
2270
  ? ["Your note steers how this clip moves. It never changes the words that are spoken."]
1537
2271
  : []),
@@ -1600,6 +2334,9 @@ export async function revoiceFlow(runId, target, json, deps) {
1600
2334
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1601
2335
  if (!runRes.ok)
1602
2336
  return errorResult(runRes, json);
2337
+ if (customerAnswer(runRes.data)) {
2338
+ return adminOnlyResult("exodus video revoice is for admins.", json);
2339
+ }
1603
2340
  const run = asVideoRun(runRes.data);
1604
2341
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1605
2342
  if (!itemsRes.ok)
@@ -1766,6 +2503,10 @@ export async function voicesFlow(runId, voices, json, deps) {
1766
2503
  : await deps.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`);
1767
2504
  if (!res.ok)
1768
2505
  return errorResult(res, json);
2506
+ const customer = customerAnswer(res.data);
2507
+ if (customer) {
2508
+ return { code: 0, lines: json ? [JSON.stringify(customer)] : customerVoicesLines(customer) };
2509
+ }
1769
2510
  const sheet = res.data;
1770
2511
  if (json)
1771
2512
  return { code: 0, lines: [JSON.stringify({ ok: true, ...sheet })] };
@@ -1848,10 +2589,112 @@ export function voiceSheetLines(sheet) {
1848
2589
  : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
1849
2590
  return lines;
1850
2591
  }
2592
+ function musicLines(manifest) {
2593
+ const lines = [`music: ${manifest.musicBed}`];
2594
+ if (manifest.musicHeardScenes.length > 0) {
2595
+ lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
2596
+ }
2597
+ return lines;
2598
+ }
2599
+ export function planCustomerPull(run, items, meta) {
2600
+ const downloads = [];
2601
+ const take = (file, url) => {
2602
+ downloads.push({ file, url });
2603
+ return file;
2604
+ };
2605
+ const scenes = new Map();
2606
+ let music = null;
2607
+ for (const item of items) {
2608
+ if (item.kind === "music") {
2609
+ if (music === null && item.url)
2610
+ music = take(`music.${extFor(item.url, "audio")}`, item.url);
2611
+ continue;
2612
+ }
2613
+ if (!isPullSceneIndex(item.sceneIndex))
2614
+ continue;
2615
+ const scene = scenes.get(item.sceneIndex) ?? { index: item.sceneIndex, clip: null, voice: null };
2616
+ if (item.url && item.kind === "clip" && scene.clip === null) {
2617
+ scene.clip = take(`${scenePrefix(item.sceneIndex)}.${extFor(item.url, "video")}`, item.url);
2618
+ }
2619
+ if (item.url && item.kind === "voice" && scene.voice === null) {
2620
+ scene.voice = take(`${scenePrefix(item.sceneIndex)}.voice.${extFor(item.url, "audio")}`, item.url);
2621
+ }
2622
+ scenes.set(item.sceneIndex, scene);
2623
+ }
2624
+ const videoUrl = run.video?.url;
2625
+ const video = videoUrl ? take(`video.${extFor(videoUrl, "video")}`, videoUrl) : null;
2626
+ return {
2627
+ downloads,
2628
+ manifest: {
2629
+ runId: run.runId,
2630
+ pulledAt: meta.pulledAt,
2631
+ dashboardUrl: customerRunPage(meta.dashboardUrl, run.runId),
2632
+ scenes: [...scenes.values()].sort((a, b) => a.index - b.index),
2633
+ music,
2634
+ video,
2635
+ },
2636
+ };
2637
+ }
2638
+ async function customerPullFlow(run, dir, json, deps) {
2639
+ const runId = run.runId;
2640
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
2641
+ if (!itemsRes.ok)
2642
+ return errorResult(itemsRes, json);
2643
+ const items = customerAnswer(itemsRes.data)?.items ?? [];
2644
+ const { downloads, manifest } = planCustomerPull(run, items, {
2645
+ pulledAt: new Date(deps.now()).toISOString(),
2646
+ dashboardUrl: deps.dashboardUrl,
2647
+ });
2648
+ deps.mkdirp(dir);
2649
+ const failures = (await runPool(downloads.map((download) => () => downloadWithRetry(download, dir, deps)), PULL_CONCURRENCY)).filter((f) => f !== null);
2650
+ const landed = (file) => file !== null && !failures.some((f) => f.file === file) ? file : null;
2651
+ for (const scene of manifest.scenes) {
2652
+ scene.clip = landed(scene.clip);
2653
+ scene.voice = landed(scene.voice);
2654
+ }
2655
+ manifest.music = landed(manifest.music);
2656
+ manifest.video = landed(manifest.video);
2657
+ const manifestPath = path.join(dir, "manifest.json");
2658
+ deps.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
2659
+ const code = failures.length > 0 ? 1 : 0;
2660
+ if (json) {
2661
+ return { code, lines: [JSON.stringify({ runId, dir, wrote: downloads.length - failures.length + 1, manifest })] };
2662
+ }
2663
+ if (downloads.length === 0) {
2664
+ return {
2665
+ code: 0,
2666
+ lines: [
2667
+ "Nothing to download yet. This video hasn't made any pieces.",
2668
+ `Check where it is: exodus video status ${runId}`,
2669
+ ],
2670
+ };
2671
+ }
2672
+ return {
2673
+ code,
2674
+ lines: [
2675
+ `Pulled ${downloads.length - failures.length + 1} files into ${dir}`,
2676
+ `Every piece is listed in ${manifestPath}.`,
2677
+ ...(manifest.video ? [] : ["The finished video isn't ready yet, so only the scene pieces came down."]),
2678
+ ...(failures.length > 0
2679
+ ? [
2680
+ "",
2681
+ `${failures.length} file${failures.length === 1 ? "" : "s"} did not download:`,
2682
+ ...failures.map((f) => ` ${f.file}: ${f.error}`),
2683
+ "Run the same command again to retry.",
2684
+ ]
2685
+ : []),
2686
+ "",
2687
+ `When your cut is ready: exodus video upload ${runId} --file cut.mp4`,
2688
+ ],
2689
+ };
2690
+ }
1851
2691
  export async function pullFlow(runId, dir, json, deps) {
1852
2692
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1853
2693
  if (!runRes.ok)
1854
2694
  return errorResult(runRes, json);
2695
+ const customer = customerAnswer(runRes.data);
2696
+ if (customer)
2697
+ return customerPullFlow(customer, dir, json, deps);
1855
2698
  const run = asVideoRun(runRes.data);
1856
2699
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1857
2700
  if (!itemsRes.ok)
@@ -1891,7 +2734,23 @@ export async function pullFlow(runId, dir, json, deps) {
1891
2734
  const lines = [
1892
2735
  `Pulled ${wrote} files into ${dir}`,
1893
2736
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2737
+ ...musicLines(plan.manifest),
1894
2738
  ];
2739
+ for (const sceneIndex of plan.manifest.pendingRedo) {
2740
+ lines.push("", `Scene ${sceneIndex} is being redone right now; its file will change. Run \`exodus video wait ${runId}\`, then pull again.`);
2741
+ }
2742
+ const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
2743
+ if (voiceFirst.length > 0) {
2744
+ lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
2745
+ `scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
2746
+ }
2747
+ const redoneUsualWay = plan.manifest.scenes.filter((s) => s.voiceFirstBlockedBy?.length);
2748
+ if (redoneUsualWay.length > 0) {
2749
+ lines.push("", "Asked for voice first, but redone the usual way:");
2750
+ for (const scene of redoneUsualWay) {
2751
+ lines.push(` scene ${scene.sceneIndex}: ${cantSayNames(scene.voiceFirstBlockedBy ?? [])}`);
2752
+ }
2753
+ }
1895
2754
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
1896
2755
  if (flagged.length > 0) {
1897
2756
  lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
@@ -2127,27 +2986,53 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
2127
2986
  const attached = attaching.value;
2128
2987
  if (!attached.ok)
2129
2988
  return errorResult(attached, json);
2989
+ const customerFinal = customerAnswer(attached.data);
2990
+ if (customerFinal) {
2991
+ const { finalWatchUrl, delivered } = customerFinal;
2992
+ if (json) {
2993
+ return {
2994
+ code: 0,
2995
+ lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
2996
+ };
2997
+ }
2998
+ return {
2999
+ code: 0,
3000
+ lines: [
3001
+ `Uploaded your cut (${durationSec.toFixed(1)}s).${delivered ? " Your video is delivered." : ""}`,
3002
+ `Watch it here: ${finalWatchUrl}`,
3003
+ ],
3004
+ };
3005
+ }
2130
3006
  const final = attached.data;
2131
3007
  const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
3008
+ const delivered = final.delivered === true;
2132
3009
  if (json) {
2133
3010
  return {
2134
3011
  code: 0,
2135
- lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl })],
3012
+ lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
2136
3013
  };
2137
3014
  }
3015
+ const uploaded = `Uploaded your cut (${durationSec.toFixed(1)}s).`;
2138
3016
  return {
2139
3017
  code: 0,
2140
- lines: [
2141
- `Uploaded your cut (${durationSec.toFixed(1)}s).`,
2142
- `Watch it here: ${finalWatchUrl}`,
2143
- `Approve it there, or run: exodus video approve ${runId}`,
2144
- ],
3018
+ lines: delivered
3019
+ ? [`${uploaded} Your ad is delivered.`, `Watch it here: ${finalWatchUrl}`]
3020
+ : [
3021
+ uploaded,
3022
+ `Watch it here: ${finalWatchUrl}`,
3023
+ `Approve it there, or run: exodus video approve ${runId}`,
3024
+ ],
2145
3025
  };
2146
3026
  }
2147
3027
  const VALUE_FLAGS = new Set([
2148
3028
  "show",
2149
3029
  "script",
3030
+ "conceit",
3031
+ "style",
3032
+ "direction",
2150
3033
  "voice-path",
3034
+ "video-model",
3035
+ "voice",
2151
3036
  "note",
2152
3037
  "out",
2153
3038
  "file",
@@ -2200,20 +3085,12 @@ export async function run(flags, occurrences) {
2200
3085
  if (sub === "shows")
2201
3086
  return printResult(await showsFlow(json, defaultDeps));
2202
3087
  if (sub === "start") {
2203
- const showId = flagString(flags, "show");
2204
- const scriptFile = flagString(flags, "script");
2205
- if (!showId)
2206
- usage("video start needs --show <id>. List them with: exodus video shows");
2207
- if (!scriptFile)
2208
- usage("video start needs --script <file>, a text file of what the ad says.");
2209
- return printResult(await startFlow({
2210
- showId,
2211
- scriptFile,
2212
- voicePath: flagString(flags, "voice-path"),
2213
- music: flags["music"] === false ? false : undefined,
2214
- wait: flags["wait"] === true,
2215
- json,
2216
- }, defaultDeps));
3088
+ const plan = planVideoStart(flags, occurrences);
3089
+ if (plan.kind === "usage")
3090
+ usage(plan.line);
3091
+ if (plan.kind === "show")
3092
+ return printResult(await startFlow(plan.opts, defaultDeps));
3093
+ return printResult(await startScriptFlow(plan.opts, defaultDeps));
2217
3094
  }
2218
3095
  const runId = rest[0];
2219
3096
  const needsRunId = [
@@ -2227,6 +3104,7 @@ export async function run(flags, occurrences) {
2227
3104
  "voices",
2228
3105
  "pull",
2229
3106
  "upload",
3107
+ "director",
2230
3108
  ];
2231
3109
  if (needsRunId.includes(sub) && !runId) {
2232
3110
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
@@ -2239,6 +3117,12 @@ export async function run(flags, occurrences) {
2239
3117
  }
2240
3118
  if (sub === "storyboard")
2241
3119
  return printResult(await storyboardFlow(runId, json, defaultDeps));
3120
+ if (sub === "director") {
3121
+ const choice = parseDirectorSwitch(rest[1]);
3122
+ if (!choice)
3123
+ usage(`video director needs on, off or follow: exodus video director ${runId} on`);
3124
+ return printResult(await directorFlow(runId, choice.on, json, defaultDeps));
3125
+ }
2242
3126
  if (sub === "approve") {
2243
3127
  return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2244
3128
  }
@@ -2249,9 +3133,7 @@ export async function run(flags, occurrences) {
2249
3133
  return printResult(await flagFlow(runId, note, json, defaultDeps));
2250
3134
  }
2251
3135
  if (sub === "retry-frame") {
2252
- const nodeId = flagString(flags, "node");
2253
- if (!nodeId)
2254
- usage("video retry-frame needs --node <nodeId>.");
3136
+ const nodeId = nonBlank(flagString(flags, "node"));
2255
3137
  const sceneRaw = flagString(flags, "scene");
2256
3138
  if (sceneRaw === undefined) {
2257
3139
  usage("video retry-frame needs --scene <n>, the scene index to redo.");
@@ -2271,7 +3153,7 @@ export async function run(flags, occurrences) {
2271
3153
  if (!Number.isInteger(sceneIndex)) {
2272
3154
  usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
2273
3155
  }
2274
- return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
3156
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
2275
3157
  }
2276
3158
  if (sub === "revoice") {
2277
3159
  const sceneRaw = flagString(flags, "scene");