@aicopycoders/exodus 2026.9.2000-beta.9 → 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,7 +7,9 @@ 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";
10
11
  const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
12
+ const VOICE_MODES = ["native", "voice-first"];
11
13
  const STYLE_SLUGS = [
12
14
  "pixar-3d",
13
15
  "claymation-stop-motion",
@@ -20,11 +22,15 @@ function joinedOr(items) {
20
22
  return items[0] ?? "";
21
23
  return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
22
24
  }
25
+ function cantSayNames(names) {
26
+ return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
27
+ }
23
28
  export const helpText = `
24
29
  exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
25
30
 
26
31
  The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
27
- 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
28
34
  not make the finished ad. You pull the pieces to a folder, cut them together
29
35
  with whatever editing tools you like, and upload the cut back.
30
36
 
@@ -68,7 +74,9 @@ The whole loop, in order:
68
74
 
69
75
  exodus video retry-clip <runId> --scene <n>
70
76
  Redo ONE finished clip while the run waits for the cut. Everything
71
- 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.
72
80
 
73
81
  exodus video revoice <runId> --all
74
82
  Redo only the VOICE on clips that kept the video model's own voice
@@ -79,7 +87,8 @@ The whole loop, in order:
79
87
  9. Make your cut from those files.
80
88
 
81
89
  10. exodus video upload <runId> --file cut.mp4
82
- 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.
83
92
 
84
93
  11. exodus video approve <runId>
85
94
  Or click Approve on the page from step 10. If you redid a clip after
@@ -90,13 +99,14 @@ Usage:
90
99
  exodus video status <runId> [--rejected-draft] [--json]
91
100
  exodus video storyboard <runId> [--json]
92
101
  exodus video approve <runId> [--approve-stale-cut] [--json]
93
- exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
94
- 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]
95
104
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
96
105
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
97
106
  exodus video pull <runId> --out <dir> [--json]
98
107
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
99
- exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--no-music] [--wait] [--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]
100
110
 
101
111
  Options:
102
112
  --out <dir> Folder to write the pulled pieces into (pull)
@@ -104,13 +114,17 @@ Options:
104
114
  --duration <sec> How long your cut is, in seconds. Only needed when the
105
115
  length can't be read off the file itself
106
116
  --note "<text>" How to steer one redo (retry-frame, retry-clip)
107
- --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).
108
119
  Which video step holds the clip, when a scene has one on
109
120
  more than one step (retry-clip, revoice)
110
121
  --scene <n> Which scene to redo. One scene only, a whole number
111
122
  (retry-frame, retry-clip, revoice)
112
123
  --all Every finished clip that kept the video model's voice
113
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
114
128
  --approve-stale-cut Deliver the cut you uploaded even though a clip changed
115
129
  after you uploaded it (approve). Without it, approving
116
130
  stops and names the scenes that changed
@@ -127,13 +141,36 @@ Options:
127
141
  --style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
128
142
  --direction "<note>" Note for the whole ad (start)
129
143
  --voice-path <path> How the voices are made (start)
130
- --no-music Leave the music bed off (start)
131
- --wait Wait until the storyboard needs a yes (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
132
161
  --json Machine-readable output
133
162
  --help, -h Print this help
134
163
 
135
- Video is admin-only. If every command here answers "video isn't enabled for
136
- 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.
137
174
 
138
175
  Examples:
139
176
  exodus workflow list
@@ -143,18 +180,21 @@ Examples:
143
180
  exodus video approve run_123
144
181
  exodus video retry-frame run_123 --node frames-1 --scene 2
145
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
146
184
  exodus video revoice run_123 --all
147
185
  exodus video voices run_123
148
186
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
149
187
  exodus video voices run_123 --from voices.json
150
188
  exodus video pull run_123 --out ./ad-run_123
151
189
  exodus video upload run_123 --file ./cut.mp4
190
+ exodus video director run_123 on
152
191
  `.trim();
153
192
  const SHOWS_PATH = "/api/v2/shows";
154
193
  const RUNS_PATH = "/api/v2/video/runs";
155
194
  const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
156
- const RUN_PATH = "/api/v2/workflow";
157
- const ITEMS_PATH = "/api/v2/workflow/items";
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";
158
198
  const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
159
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 " +
160
200
  "with the next server update.";
@@ -163,12 +203,15 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
163
203
  const APPROVE_PATH = "/api/v2/workflow/approve";
164
204
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
165
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.";
166
209
  const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
167
210
  "was spent. It arrives with the next server update.";
168
- const FINAL_PATH = "/api/v2/video/final";
211
+ export const FINAL_PATH = "/api/v2/video/final";
169
212
  export const VOICES_PATH = "/api/v2/video/voices";
170
- const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
171
- 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";
172
215
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
173
216
  return rest;
174
217
  }
@@ -177,7 +220,7 @@ export function qcWithoutJudgeWording(qc) {
177
220
  return qc;
178
221
  return {
179
222
  ...qc,
180
- takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
223
+ takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
181
224
  };
182
225
  }
183
226
  export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
@@ -206,6 +249,9 @@ export function renderQcTakeHistory(takes) {
206
249
  });
207
250
  return lines;
208
251
  }
252
+ function redoLine(lastRedo) {
253
+ return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
254
+ }
209
255
  function printDisplacedHistory(lines, lastRedo) {
210
256
  const history = lastRedo.displacedHistory;
211
257
  if (!history)
@@ -270,7 +316,9 @@ export const defaultDeps = {
270
316
  dashboardUrl: getDashboardUrl(),
271
317
  };
272
318
  export const VIDEO_NOT_FOUND_MESSAGE = "video isn't enabled for this key\n" +
273
- "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.";
274
322
  export function videoApiError(res) {
275
323
  const behind = missingRouteLine(res, "exodus video");
276
324
  if (behind)
@@ -279,6 +327,9 @@ export function videoApiError(res) {
279
327
  return VIDEO_NOT_FOUND_MESSAGE;
280
328
  return formatApiError(res);
281
329
  }
330
+ function adminOnlyResult(line, json) {
331
+ return { code: 1, lines: json ? [JSON.stringify({ ok: false, error: line })] : [line] };
332
+ }
282
333
  function errorResult(res, json) {
283
334
  return {
284
335
  code: 1,
@@ -301,6 +352,14 @@ function parkedAtFinalWatch(run) {
301
352
  return (!parkedByBuilderCheckpoint(run) &&
302
353
  run.nodes.some((n) => n.kind === "video" && n.status === "done"));
303
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
+ }
304
363
  export function classifyRun(run) {
305
364
  if (run.status === "failed")
306
365
  return { at: "failed", error: run.error };
@@ -320,6 +379,7 @@ export function classifyRun(run) {
320
379
  }
321
380
  const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
322
381
  if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
382
+ const held = heldReason(run);
323
383
  return {
324
384
  at: "storyboard-gate",
325
385
  nodeId: pausedNode.nodeId,
@@ -327,10 +387,31 @@ export function classifyRun(run) {
327
387
  ? { framesNodeId: pausedNode.nodeId }
328
388
  : {}),
329
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 } : {}),
330
413
  };
331
414
  }
332
- if (parkedAtFinalWatch(run))
333
- return { at: "final-watch" };
334
415
  return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
335
416
  }
336
417
  const active = run.nodes.find((n) => n.status === "running");
@@ -339,10 +420,22 @@ export function classifyRun(run) {
339
420
  export function hasAttachedCut(items) {
340
421
  return items.some((i) => i.itemKind === "final" && i.status === "done");
341
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
+ }
342
435
  export function resolveStop(stop, cutAttached) {
343
436
  if (stop.at !== "final-watch")
344
437
  return stop;
345
- return { at: "final-watch", cutAttached };
438
+ return { ...stop, cutAttached };
346
439
  }
347
440
  export async function resolveStopAtPark(stop, runId, deps) {
348
441
  if (stop.at !== "final-watch")
@@ -381,15 +474,38 @@ export function isShowAd(run) {
381
474
  return run.moduleOwned === true;
382
475
  }
383
476
  export function reviewUrl(dashboardUrl, run) {
384
- if (isShowAd(run))
385
- return `${dashboardUrl}/video?ad=${run._id}`;
386
477
  if (run.workflowId)
387
478
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
388
479
  return `${dashboardUrl}/runs/${run._id}`;
389
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
+ }
390
505
  export function stopLines(stop, runId, runUrl) {
391
506
  if (stop.at === "storyboard-gate") {
392
507
  const lines = [
508
+ ...(stop.heldReason ? [stop.heldReason] : []),
393
509
  "Parked: the storyboard is waiting for your yes.",
394
510
  `Read it: exodus video storyboard ${runId}`,
395
511
  `Approve it: exodus video approve ${runId}`,
@@ -403,6 +519,9 @@ export function stopLines(stop, runId, runUrl) {
403
519
  return lines;
404
520
  }
405
521
  if (stop.at === "final-watch") {
522
+ if (stop.missingScenes?.length) {
523
+ return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
524
+ }
406
525
  if (stop.cutAttached === null) {
407
526
  return [
408
527
  "Parked: every piece is made.",
@@ -458,8 +577,28 @@ export function stopLines(stop, runId, runUrl) {
458
577
  `Open it: ${runUrl}`,
459
578
  ];
460
579
  }
580
+ if (stop.autoRedoScenes?.length)
581
+ return [autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)];
461
582
  return [`Working: ${stageWord(stop.stage)}.`];
462
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
+ }
463
602
  export function failedStoryboardNode(run) {
464
603
  return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
465
604
  }
@@ -484,7 +623,13 @@ export function planFailureLines(node, runId) {
484
623
  }
485
624
  return lines;
486
625
  }
626
+ export const MUSIC_HEARD_CODE = "music-heard";
487
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
+ }
488
633
  function isPullSceneIndex(sceneIndex) {
489
634
  return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
490
635
  }
@@ -526,6 +671,10 @@ function clipFromArtifact(sceneIndex, artifact) {
526
671
  revoiced: artifact.revoiced === true,
527
672
  speechTrimmed: artifact.speechTrimmed === true,
528
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,
529
678
  };
530
679
  }
531
680
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -677,14 +826,36 @@ export function planPull(run, items, opts) {
677
826
  }
678
827
  }
679
828
  let reference = null;
829
+ let productSheet = null;
830
+ let setPlate = null;
831
+ const filledByStage = new Map();
680
832
  for (const artifact of outputsOfNodeKind(run, "reference")) {
681
833
  if (artifact.type !== "image" || !artifact.imageUrl)
682
834
  continue;
683
- reference = {
684
- file: `reference.${extFor(artifact.imageUrl, "image")}`,
685
- url: artifact.imageUrl,
686
- };
687
- }
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
+ }));
688
859
  const keyframeByScene = new Map();
689
860
  for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
690
861
  if (artifact.type !== "frames")
@@ -787,6 +958,9 @@ export function planPull(run, items, opts) {
787
958
  continue;
788
959
  keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
789
960
  }
961
+ const pendingRedo = scenesBeingRedone(run, items);
962
+ for (const sceneIndex of pendingRedo)
963
+ clipByScene.delete(sceneIndex);
790
964
  const sceneIndexes = [
791
965
  ...new Set([
792
966
  ...clipByScene.keys(),
@@ -835,6 +1009,10 @@ export function planPull(run, items, opts) {
835
1009
  revoiced: clip ? clip.revoiced : null,
836
1010
  speechTrimmed: clip ? clip.speechTrimmed : null,
837
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,
838
1016
  clipStatus: item?.status ?? "missing",
839
1017
  error: item?.error ?? null,
840
1018
  flagged: item?.flagged === true,
@@ -845,6 +1023,9 @@ export function planPull(run, items, opts) {
845
1023
  const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
846
1024
  const downloads = [
847
1025
  ...(reference ? [reference] : []),
1026
+ ...(productSheet ? [productSheet] : []),
1027
+ ...(setPlate ? [setPlate] : []),
1028
+ ...filledPlates.map((p) => p.download),
848
1029
  ...keyframeByScene.values(),
849
1030
  ...[...voiceByScene.values()].map((v) => v.download),
850
1031
  ...(narrationDownload ? [narrationDownload] : []),
@@ -861,7 +1042,15 @@ export function planPull(run, items, opts) {
861
1042
  dashboardUrl: reviewUrl(opts.dashboardUrl, run),
862
1043
  storyboard,
863
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 } : {}) })),
864
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,
865
1054
  cast,
866
1055
  narration: narrationDownload
867
1056
  ? { file: narrationDownload.file, timing: "narration.json" }
@@ -871,6 +1060,14 @@ export function planPull(run, items, opts) {
871
1060
  ...(run.provenance?.format || run.provenance?.voice
872
1061
  ? { provenance: run.provenance }
873
1062
  : {}),
1063
+ ...(run.videoChoice
1064
+ ? {
1065
+ videoChoice: {
1066
+ videoModel: run.videoChoice.videoModel,
1067
+ voiceMode: run.videoChoice.voiceMode,
1068
+ },
1069
+ }
1070
+ : {}),
874
1071
  },
875
1072
  };
876
1073
  }
@@ -880,6 +1077,14 @@ export function markPullFailure(manifest, failure) {
880
1077
  manifest.storyboard = null;
881
1078
  if (manifest.reference === failure.file)
882
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
+ }
883
1088
  if (manifest.music === failure.file)
884
1089
  manifest.music = null;
885
1090
  for (const ref of manifest.cast) {
@@ -896,6 +1101,10 @@ export function markPullFailure(manifest, failure) {
896
1101
  scene.revoiced = null;
897
1102
  scene.speechTrimmed = null;
898
1103
  scene.rawStorageId = null;
1104
+ scene.voiceMode = null;
1105
+ scene.voiceFirstBlockedBy = null;
1106
+ scene.voicePitchHz = null;
1107
+ scene.voicePitchMethod = null;
899
1108
  }
900
1109
  if (scene.voice === failure.file)
901
1110
  scene.voice = null;
@@ -1001,7 +1210,7 @@ export async function startFlow(opts, deps) {
1001
1210
  showId: opts.showId,
1002
1211
  script,
1003
1212
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1004
- ...(opts.music === false ? { music: false } : {}),
1213
+ ...(opts.music === undefined ? {} : { music: opts.music }),
1005
1214
  });
1006
1215
  if (!res.ok)
1007
1216
  return errorResult(res, opts.json);
@@ -1035,10 +1244,51 @@ function nonBlank(value) {
1035
1244
  return undefined;
1036
1245
  return value;
1037
1246
  }
1247
+ function isVoiceMode(value) {
1248
+ return VOICE_MODES.some((mode) => mode === value);
1249
+ }
1038
1250
  function isConceit(value) {
1039
1251
  return CONCEIT_KEYS.some((key) => key === value);
1040
1252
  }
1041
- export function planVideoStart(flags) {
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) {
1042
1292
  const showId = nonBlank(flagString(flags, "show"));
1043
1293
  const scriptFile = nonBlank(flagString(flags, "script"));
1044
1294
  const conceit = nonBlank(flagString(flags, "conceit"));
@@ -1055,6 +1305,50 @@ export function planVideoStart(flags) {
1055
1305
  line: "video start needs --script <file>, a text file of what the ad says.",
1056
1306
  };
1057
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
+ }
1058
1352
  if (showId) {
1059
1353
  return {
1060
1354
  kind: "show",
@@ -1062,7 +1356,7 @@ export function planVideoStart(flags) {
1062
1356
  showId,
1063
1357
  scriptFile,
1064
1358
  voicePath: flagString(flags, "voice-path"),
1065
- music: flags["music"] === false ? false : undefined,
1359
+ music: musicChoice.music,
1066
1360
  wait: flags["wait"] === true,
1067
1361
  json: flags["json"] === true,
1068
1362
  },
@@ -1099,8 +1393,16 @@ export function planVideoStart(flags) {
1099
1393
  opts.direction = direction;
1100
1394
  if (voicePath)
1101
1395
  opts.voicePath = voicePath;
1102
- if (flags["music"] === false)
1103
- opts.music = false;
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;
1104
1406
  return { kind: "script", opts };
1105
1407
  }
1106
1408
  function isRecord(value) {
@@ -1123,7 +1425,14 @@ function isResolvedCard(value) {
1123
1425
  return false;
1124
1426
  return true;
1125
1427
  }
1126
- function scriptStartedLines(runId, url, card) {
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) {
1127
1436
  return [
1128
1437
  `Started ad run ${runId}`,
1129
1438
  ...(card
@@ -1134,10 +1443,12 @@ function scriptStartedLines(runId, url, card) {
1134
1443
  `Style: ${card.style.label}`,
1135
1444
  ]
1136
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"] : []),
1137
1448
  `Watch it: ${url}`,
1138
1449
  ];
1139
1450
  }
1140
- function waitJsonWithCard(waited, card) {
1451
+ function waitJsonWithCard(waited, card, video) {
1141
1452
  const first = waited.lines[0];
1142
1453
  if (typeof first !== "string")
1143
1454
  return waited;
@@ -1150,7 +1461,10 @@ function waitJsonWithCard(waited, card) {
1150
1461
  }
1151
1462
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1152
1463
  return waited;
1153
- return { code: waited.code, lines: [JSON.stringify({ ...parsed, card })] };
1464
+ return {
1465
+ code: waited.code,
1466
+ lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1467
+ };
1154
1468
  }
1155
1469
  export async function startScriptFlow(opts, deps) {
1156
1470
  let script;
@@ -1174,10 +1488,17 @@ export async function startScriptFlow(opts, deps) {
1174
1488
  style: opts.style,
1175
1489
  ...(opts.direction ? { direction: opts.direction } : {}),
1176
1490
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1177
- ...(opts.music === false ? { music: false } : {}),
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 } : {}),
1178
1496
  });
1179
1497
  if (!res.ok)
1180
1498
  return errorResult(res, opts.json);
1499
+ const customerStart = customerAnswer(res.data);
1500
+ if (customerStart)
1501
+ return customerStartedResult(customerStart, opts, deps);
1181
1502
  const started = res.data;
1182
1503
  if (!started.runId) {
1183
1504
  return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
@@ -1185,25 +1506,52 @@ export async function startScriptFlow(opts, deps) {
1185
1506
  const runId = started.runId;
1186
1507
  const url = typeof started.url === "string" ? started.url : "";
1187
1508
  const card = isResolvedCard(started.card) ? started.card : null;
1509
+ const video = startedVideoChoice(started.videoChoice);
1188
1510
  if (!opts.wait) {
1189
1511
  return {
1190
1512
  code: 0,
1191
1513
  lines: opts.json
1192
- ? [JSON.stringify({ runId, url, card })]
1514
+ ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1193
1515
  : [
1194
- ...scriptStartedLines(runId, url, card),
1516
+ ...scriptStartedLines(runId, url, card, video, opts.director === true),
1195
1517
  "",
1196
- "Wait for the storyboard here instead: exodus video start … --wait",
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
+ ]),
1197
1524
  `Or check in whenever: exodus video status ${runId}`,
1198
1525
  ],
1199
1526
  };
1200
1527
  }
1201
1528
  const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1202
1529
  if (opts.json)
1203
- return waitJsonWithCard(waited, card);
1530
+ return waitJsonWithCard(waited, card, video);
1204
1531
  return {
1205
1532
  code: waited.code,
1206
- lines: [...scriptStartedLines(runId, url, card), "", ...waited.lines],
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
+ ],
1207
1555
  };
1208
1556
  }
1209
1557
  const POLL_INTERVAL_MS = 5000;
@@ -1212,7 +1560,8 @@ export async function waitFlow(runId, opts, deps) {
1212
1560
  const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
1213
1561
  const maxPolls = opts.maxPolls ?? MAX_POLLS;
1214
1562
  const lines = [];
1215
- let lastStage = null;
1563
+ let lastProgress = null;
1564
+ let redoSeen = false;
1216
1565
  for (let poll = 0; poll < maxPolls; poll++) {
1217
1566
  if (poll > 0)
1218
1567
  await deps.sleep(interval);
@@ -1221,17 +1570,57 @@ export async function waitFlow(runId, opts, deps) {
1221
1570
  const failed = errorResult(res, opts.json);
1222
1571
  return { code: failed.code, lines: [...lines, ...failed.lines] };
1223
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
+ }
1224
1592
  const run = asVideoRun(res.data);
1225
1593
  const stop = classifyRun(run);
1226
1594
  if (stop.at === "running") {
1227
- if (stop.stage !== lastStage) {
1228
- 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;
1229
1600
  if (!opts.json)
1230
- lines.push(`Working: ${stageWord(stop.stage)}…`);
1601
+ lines.push(progress);
1231
1602
  }
1232
1603
  continue;
1233
1604
  }
1234
- 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);
1235
1624
  if (opts.json) {
1236
1625
  return {
1237
1626
  code: stop.at === "failed" ? 1 : 0,
@@ -1274,6 +1663,13 @@ export async function statusFlow(runId, json, deps) {
1274
1663
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1275
1664
  if (!runRes.ok)
1276
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
+ }
1277
1673
  const run = asVideoRun(runRes.data);
1278
1674
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1279
1675
  if (!itemsRes.ok)
@@ -1300,18 +1696,32 @@ export async function statusFlow(runId, json, deps) {
1300
1696
  hasFinal,
1301
1697
  warnings,
1302
1698
  guidance,
1699
+ ...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
1303
1700
  ...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
1304
1701
  ...(failedStoryboard?.planFailure
1305
1702
  ? { storyboardNodeId: failedStoryboard.nodeId }
1306
1703
  : {}),
1307
1704
  ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1705
+ ...(run.director ? { director: run.director } : {}),
1308
1706
  }),
1309
1707
  ],
1310
1708
  };
1311
1709
  }
1312
1710
  const revoicedByScene = new Set();
1711
+ const voiceFirstByScene = new Set();
1712
+ const voiceFirstBlockedByScene = new Map();
1313
1713
  const timedOnOriginalVoice = new Set();
1314
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
+ }
1315
1725
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
1316
1726
  revoicedByScene.add(artifact.sceneIndex);
1317
1727
  if ((artifact.words?.length ?? 0) > 0)
@@ -1342,6 +1752,7 @@ export async function statusFlow(runId, json, deps) {
1342
1752
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1343
1753
  ...planFailureLines(failedStoryboard, runId),
1344
1754
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
1755
+ ...(run.director ? ["", ...directorLines(run.director)] : []),
1345
1756
  ];
1346
1757
  if (byScene.size === 0) {
1347
1758
  lines.push("", "No scenes yet — this run hasn't made anything to look at.");
@@ -1352,7 +1763,7 @@ export async function statusFlow(runId, json, deps) {
1352
1763
  const row = byScene.get(sceneIndex);
1353
1764
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1354
1765
  if (row.clip?.lastRedo) {
1355
- lines.push(` clip: ${row.clip.lastRedo.label}`);
1766
+ lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
1356
1767
  printDisplacedHistory(lines, row.clip.lastRedo);
1357
1768
  }
1358
1769
  if (row.clip?.lastRedo || row.clip?.flagged) {
@@ -1361,7 +1772,7 @@ export async function statusFlow(runId, json, deps) {
1361
1772
  }
1362
1773
  }
1363
1774
  if (row.voiceover?.lastRedo) {
1364
- lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1775
+ lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
1365
1776
  printDisplacedHistory(lines, row.voiceover.lastRedo);
1366
1777
  }
1367
1778
  if (row.frame?.lastRedo)
@@ -1380,6 +1791,12 @@ export async function statusFlow(runId, json, deps) {
1380
1791
  lines.push(` ${row.clip.error}`);
1381
1792
  if (revoicedByScene.has(sceneIndex))
1382
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
+ }
1383
1800
  if (timedOnOriginalVoice.has(sceneIndex)) {
1384
1801
  lines.push(" word timings: measured on the original voice (close, not frame-exact)");
1385
1802
  }
@@ -1398,10 +1815,79 @@ export async function statusFlow(runId, json, deps) {
1398
1815
  : "Final cut: not uploaded yet.");
1399
1816
  return { code: 0, lines };
1400
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
+ }
1401
1884
  export async function rejectedDraftFlow(runId, json, deps) {
1402
1885
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1403
1886
  if (!runRes.ok)
1404
1887
  return errorResult(runRes, json);
1888
+ if (customerAnswer(runRes.data)) {
1889
+ return adminOnlyResult("exodus video status --rejected-draft is for admins.", json);
1890
+ }
1405
1891
  const node = failedStoryboardNode(asVideoRun(runRes.data));
1406
1892
  if (!node) {
1407
1893
  const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
@@ -1441,6 +1927,10 @@ export async function storyboardFlow(runId, json, deps) {
1441
1927
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
1442
1928
  if (!res.ok)
1443
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
+ }
1444
1934
  const cards = res.data;
1445
1935
  if (json)
1446
1936
  return { code: 0, lines: [JSON.stringify(cards)] };
@@ -1579,14 +2069,34 @@ export async function flagFlow(runId, note, json, deps) {
1579
2069
  };
1580
2070
  }
1581
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
+ }
1582
2080
  const res = await deps.post(SCENE_RETRY_PATH, {
1583
2081
  runId,
1584
- nodeId,
2082
+ ...(nodeId === undefined ? {} : { nodeId }),
1585
2083
  sceneIndex,
1586
2084
  ...(note ? { note } : {}),
1587
2085
  });
1588
2086
  if (!res.ok)
1589
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
+ }
1590
2100
  const triggerRunId = res.data.triggerRunId;
1591
2101
  if (json) {
1592
2102
  return {
@@ -1623,7 +2133,10 @@ export function planClipRedo(run, items, target) {
1623
2133
  const { row, uploadedCut } = found;
1624
2134
  const redoable = row.status === "failed" ||
1625
2135
  row.staleClaim === true ||
1626
- (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)));
1627
2140
  if (!redoable) {
1628
2141
  if (row.status === "done") {
1629
2142
  return {
@@ -1680,10 +2193,14 @@ function findClipRow(run, items, target) {
1680
2193
  }
1681
2194
  return { row, uploadedCut };
1682
2195
  }
1683
- export async function retryClipFlow(runId, target, note, json, deps) {
2196
+ export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
1684
2197
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1685
2198
  if (!runRes.ok)
1686
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
+ }
1687
2204
  const run = asVideoRun(runRes.data);
1688
2205
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1689
2206
  if (!itemsRes.ok)
@@ -1696,12 +2213,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1696
2213
  lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1697
2214
  };
1698
2215
  }
1699
- const res = await deps.post(SCENE_RETRY_PATH, {
2216
+ const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
1700
2217
  runId,
1701
2218
  nodeId: plan.nodeId,
1702
2219
  sceneIndex: plan.sceneIndex,
1703
2220
  ...(note ? { note } : {}),
1704
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
+ }
1705
2230
  if (!res.ok)
1706
2231
  return errorResult(res, json);
1707
2232
  const triggerRunId = res.data.triggerRunId;
@@ -1720,6 +2245,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1720
2245
  reviewUrl: review,
1721
2246
  warnings: plan.warnings,
1722
2247
  ...(note ? { note } : {}),
2248
+ ...(voiceFirst ? { voiceMode: "voice-first" } : {}),
1723
2249
  }),
1724
2250
  ],
1725
2251
  };
@@ -1727,9 +2253,19 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1727
2253
  return {
1728
2254
  code: 0,
1729
2255
  lines: [
1730
- `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}.`,
1731
2262
  `triggerRunId: ${triggerRunId ?? "-"}`,
1732
- "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."]),
1733
2269
  ...(note
1734
2270
  ? ["Your note steers how this clip moves. It never changes the words that are spoken."]
1735
2271
  : []),
@@ -1798,6 +2334,9 @@ export async function revoiceFlow(runId, target, json, deps) {
1798
2334
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1799
2335
  if (!runRes.ok)
1800
2336
  return errorResult(runRes, json);
2337
+ if (customerAnswer(runRes.data)) {
2338
+ return adminOnlyResult("exodus video revoice is for admins.", json);
2339
+ }
1801
2340
  const run = asVideoRun(runRes.data);
1802
2341
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1803
2342
  if (!itemsRes.ok)
@@ -1964,6 +2503,10 @@ export async function voicesFlow(runId, voices, json, deps) {
1964
2503
  : await deps.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`);
1965
2504
  if (!res.ok)
1966
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
+ }
1967
2510
  const sheet = res.data;
1968
2511
  if (json)
1969
2512
  return { code: 0, lines: [JSON.stringify({ ok: true, ...sheet })] };
@@ -2046,10 +2589,112 @@ export function voiceSheetLines(sheet) {
2046
2589
  : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
2047
2590
  return lines;
2048
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
+ }
2049
2691
  export async function pullFlow(runId, dir, json, deps) {
2050
2692
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
2051
2693
  if (!runRes.ok)
2052
2694
  return errorResult(runRes, json);
2695
+ const customer = customerAnswer(runRes.data);
2696
+ if (customer)
2697
+ return customerPullFlow(customer, dir, json, deps);
2053
2698
  const run = asVideoRun(runRes.data);
2054
2699
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
2055
2700
  if (!itemsRes.ok)
@@ -2089,7 +2734,23 @@ export async function pullFlow(runId, dir, json, deps) {
2089
2734
  const lines = [
2090
2735
  `Pulled ${wrote} files into ${dir}`,
2091
2736
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2737
+ ...musicLines(plan.manifest),
2092
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
+ }
2093
2754
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
2094
2755
  if (flagged.length > 0) {
2095
2756
  lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
@@ -2325,21 +2986,42 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
2325
2986
  const attached = attaching.value;
2326
2987
  if (!attached.ok)
2327
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
+ }
2328
3006
  const final = attached.data;
2329
3007
  const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
3008
+ const delivered = final.delivered === true;
2330
3009
  if (json) {
2331
3010
  return {
2332
3011
  code: 0,
2333
- lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl })],
3012
+ lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
2334
3013
  };
2335
3014
  }
3015
+ const uploaded = `Uploaded your cut (${durationSec.toFixed(1)}s).`;
2336
3016
  return {
2337
3017
  code: 0,
2338
- lines: [
2339
- `Uploaded your cut (${durationSec.toFixed(1)}s).`,
2340
- `Watch it here: ${finalWatchUrl}`,
2341
- `Approve it there, or run: exodus video approve ${runId}`,
2342
- ],
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
+ ],
2343
3025
  };
2344
3026
  }
2345
3027
  const VALUE_FLAGS = new Set([
@@ -2349,6 +3031,8 @@ const VALUE_FLAGS = new Set([
2349
3031
  "style",
2350
3032
  "direction",
2351
3033
  "voice-path",
3034
+ "video-model",
3035
+ "voice",
2352
3036
  "note",
2353
3037
  "out",
2354
3038
  "file",
@@ -2401,7 +3085,7 @@ export async function run(flags, occurrences) {
2401
3085
  if (sub === "shows")
2402
3086
  return printResult(await showsFlow(json, defaultDeps));
2403
3087
  if (sub === "start") {
2404
- const plan = planVideoStart(flags);
3088
+ const plan = planVideoStart(flags, occurrences);
2405
3089
  if (plan.kind === "usage")
2406
3090
  usage(plan.line);
2407
3091
  if (plan.kind === "show")
@@ -2420,6 +3104,7 @@ export async function run(flags, occurrences) {
2420
3104
  "voices",
2421
3105
  "pull",
2422
3106
  "upload",
3107
+ "director",
2423
3108
  ];
2424
3109
  if (needsRunId.includes(sub) && !runId) {
2425
3110
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
@@ -2432,6 +3117,12 @@ export async function run(flags, occurrences) {
2432
3117
  }
2433
3118
  if (sub === "storyboard")
2434
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
+ }
2435
3126
  if (sub === "approve") {
2436
3127
  return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2437
3128
  }
@@ -2442,9 +3133,7 @@ export async function run(flags, occurrences) {
2442
3133
  return printResult(await flagFlow(runId, note, json, defaultDeps));
2443
3134
  }
2444
3135
  if (sub === "retry-frame") {
2445
- const nodeId = flagString(flags, "node");
2446
- if (!nodeId)
2447
- usage("video retry-frame needs --node <nodeId>.");
3136
+ const nodeId = nonBlank(flagString(flags, "node"));
2448
3137
  const sceneRaw = flagString(flags, "scene");
2449
3138
  if (sceneRaw === undefined) {
2450
3139
  usage("video retry-frame needs --scene <n>, the scene index to redo.");
@@ -2464,7 +3153,7 @@ export async function run(flags, occurrences) {
2464
3153
  if (!Number.isInteger(sceneIndex)) {
2465
3154
  usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
2466
3155
  }
2467
- 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));
2468
3157
  }
2469
3158
  if (sub === "revoice") {
2470
3159
  const sceneRaw = flagString(flags, "scene");