@aicopycoders/exodus 2026.9.2000-beta.9 → 2026.10.1200-beta.1

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,24 +7,37 @@ 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
- const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
11
- const STYLE_SLUGS = [
12
- "pixar-3d",
13
- "claymation-stop-motion",
14
- "lego-toy-brick",
15
- "balloon",
16
- "video-game-8bit",
17
- ];
10
+ import { customerAnswer, customerRunPage, customerStageIsWorking, customerStageLines, customerStatusLines, customerStoryboardLines, customerVoicesLines, } from "./video-customer.js";
11
+ const VOICE_MODES = ["native", "voice-first"];
18
12
  function joinedOr(items) {
19
13
  if (items.length <= 1)
20
14
  return items[0] ?? "";
21
15
  return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
22
16
  }
17
+ export function scriptRunPickProblem(options, conceit, style) {
18
+ const keys = options.conceits.map((row) => row.key);
19
+ if (!keys.includes(conceit)) {
20
+ return keys.length === 0
21
+ ? "This Exodus server offers no kinds of ad yet."
22
+ : `video start --conceit must be one of ${joinedOr(keys)}.`;
23
+ }
24
+ const slugs = options.styles.map((row) => row.slug);
25
+ if (!slugs.includes(style)) {
26
+ return slugs.length === 0
27
+ ? "This Exodus server offers no looks yet."
28
+ : `video start --style must be one of ${joinedOr(slugs)}.`;
29
+ }
30
+ return null;
31
+ }
32
+ function cantSayNames(names) {
33
+ return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
34
+ }
23
35
  export const helpText = `
24
36
  exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
25
37
 
26
38
  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
39
+ one voice track per scene, one video clip per scene, and a music bed if you
40
+ asked for one with --music. It does
28
41
  not make the finished ad. You pull the pieces to a folder, cut them together
29
42
  with whatever editing tools you like, and upload the cut back.
30
43
 
@@ -68,7 +81,9 @@ The whole loop, in order:
68
81
 
69
82
  exodus video retry-clip <runId> --scene <n>
70
83
  Redo ONE finished clip while the run waits for the cut. Everything
71
- else stays. A --note steers the motion, never the words.
84
+ else stays. A --note steers the motion, never the words. Add
85
+ --voice-first when the words came out wrong: the line is recorded in
86
+ the character's own voice first, then the clip is made to match it.
72
87
 
73
88
  exodus video revoice <runId> --all
74
89
  Redo only the VOICE on clips that kept the video model's own voice
@@ -79,7 +94,8 @@ The whole loop, in order:
79
94
  9. Make your cut from those files.
80
95
 
81
96
  10. exodus video upload <runId> --file cut.mp4
82
- Attaches your cut to the run and prints the page to approve it on.
97
+ Attaches your cut to the run and prints the page to watch it on. On a
98
+ One-shot run the upload delivers the ad, so step 11 is not needed.
83
99
 
84
100
  11. exodus video approve <runId>
85
101
  Or click Approve on the page from step 10. If you redid a clip after
@@ -90,13 +106,14 @@ Usage:
90
106
  exodus video status <runId> [--rejected-draft] [--json]
91
107
  exodus video storyboard <runId> [--json]
92
108
  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]
109
+ exodus video retry-frame <runId> [--node <nodeId>] --scene <n> [--note "..."] [--json]
110
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
95
111
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
96
112
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
97
113
  exodus video pull <runId> --out <dir> [--json]
98
114
  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]
115
+ exodus video start --script <file> --conceit <kind> --style <look> [--direction "<note>"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--review-storyboard] [--director] [--wait] [--json]
116
+ exodus video director <runId> <on|off|follow> [--json]
100
117
 
101
118
  Options:
102
119
  --out <dir> Folder to write the pulled pieces into (pull)
@@ -104,13 +121,17 @@ Options:
104
121
  --duration <sec> How long your cut is, in seconds. Only needed when the
105
122
  length can't be read off the file itself
106
123
  --note "<text>" How to steer one redo (retry-frame, retry-clip)
107
- --node <nodeId> Which scene-frames node holds the still (retry-frame).
124
+ --node <nodeId> Which scene-frames node holds the still (retry-frame;
125
+ admins pass it, beta testers leave it off).
108
126
  Which video step holds the clip, when a scene has one on
109
127
  more than one step (retry-clip, revoice)
110
128
  --scene <n> Which scene to redo. One scene only, a whole number
111
129
  (retry-frame, retry-clip, revoice)
112
130
  --all Every finished clip that kept the video model's voice
113
131
  (revoice)
132
+ --voice-first Record the scene's line in the character's own voice,
133
+ then make the clip to match it (retry-clip). For a clip
134
+ whose words came out wrong
114
135
  --approve-stale-cut Deliver the cut you uploaded even though a clip changed
115
136
  after you uploaded it (approve). Without it, approving
116
137
  stops and names the scenes that changed
@@ -123,17 +144,47 @@ Options:
123
144
  --rejected-draft Print the storyboard draft the system turned down
124
145
  (status). It was thrown away — a record only
125
146
  --script <file> Text file of what the ad says (start)
126
- --conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
127
- --style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
147
+ --conceit <kind> Kind of ad (start)
148
+ --style <look> Look (start)
149
+ The server lists the kinds and looks it offers. An unknown word prints them.
128
150
  --direction "<note>" Note for the whole ad (start)
151
+ --frame pov Camera-as-character POV: the camera is a person in the
152
+ scene (start, with --conceit). Leave it off for a
153
+ single frame. Conversation ads only
154
+ --pacing <pace> jump-cuts, long-take or montage (start, with
155
+ --conceit). Leave it off for natural pacing. Presenter
156
+ ads only
129
157
  --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)
158
+ --video-model <id> Which video model films this ad, instead of the kind of
159
+ ad's own default (start, with --conceit)
160
+ --voice <mode> voice-first (the default): the lines are recorded
161
+ first. native: the video model speaks the lines
162
+ (start, with --conceit)
163
+ --music Put a music bed under the ad (start). An ad has no music
164
+ unless you ask for it here
165
+ --no-music Leave the music bed off (start), which it already is
166
+ --review-storyboard Stop at the storyboard so you can approve it yourself
167
+ (start, with --conceit). By default the app approves its
168
+ own storyboard when its checks pass
169
+ --director Turn the trial director on for this ad (start, with
170
+ --conceit). Admins only. To switch it on or off later:
171
+ exodus video director <runId> on|off|follow, where
172
+ follow goes back to the brand's own setting
173
+ --wait Wait until the run needs you (start): at the
174
+ storyboard, or once every piece is made
132
175
  --json Machine-readable output
133
176
  --help, -h Print this help
134
177
 
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.
178
+ Who can use what. Admins get every command here. Beta testers with video
179
+ switched on get these, on their own brands: start (--script, --conceit,
180
+ --style, --direction, --frame, --pacing, --music, --review-storyboard),
181
+ status, storyboard, approve (the storyboard), voices, retry-frame --scene <n> (redo one scene's
182
+ picture while the storyboard waits), pull (the finished video, each scene's
183
+ clip and voice, and the music) and upload. These stay admin-only:
184
+ start --video-model / --voice / --voice-path / --director, director,
185
+ status --rejected-draft, retry-clip and revoice.
186
+ If a command answers "video isn't enabled for this key", the run id is wrong,
187
+ the command is admin-only, or video isn't switched on for your dashboard user.
137
188
 
138
189
  Examples:
139
190
  exodus workflow list
@@ -143,18 +194,21 @@ Examples:
143
194
  exodus video approve run_123
144
195
  exodus video retry-frame run_123 --node frames-1 --scene 2
145
196
  exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
197
+ exodus video retry-clip run_123 --scene 3 --voice-first
146
198
  exodus video revoice run_123 --all
147
199
  exodus video voices run_123
148
200
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
149
201
  exodus video voices run_123 --from voices.json
150
202
  exodus video pull run_123 --out ./ad-run_123
151
203
  exodus video upload run_123 --file ./cut.mp4
204
+ exodus video director run_123 on
152
205
  `.trim();
153
206
  const SHOWS_PATH = "/api/v2/shows";
154
207
  const RUNS_PATH = "/api/v2/video/runs";
155
208
  const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
156
- const RUN_PATH = "/api/v2/workflow";
157
- const ITEMS_PATH = "/api/v2/workflow/items";
209
+ const DIRECTOR_PATH = "/api/v2/video/director";
210
+ export const RUN_PATH = "/api/v2/workflow";
211
+ export const ITEMS_PATH = "/api/v2/workflow/items";
158
212
  const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
159
213
  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
214
  "with the next server update.";
@@ -163,12 +217,15 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
163
217
  const APPROVE_PATH = "/api/v2/workflow/approve";
164
218
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
165
219
  const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
220
+ const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
221
+ const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
222
+ "was spent. It arrives with the next server update.";
166
223
  const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
167
224
  "was spent. It arrives with the next server update.";
168
- const FINAL_PATH = "/api/v2/video/final";
225
+ export const FINAL_PATH = "/api/v2/video/final";
169
226
  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";
227
+ export const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
228
+ export const ASSETS_PATH = "/api/v2/workflows/assets";
172
229
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
173
230
  return rest;
174
231
  }
@@ -177,7 +234,7 @@ export function qcWithoutJudgeWording(qc) {
177
234
  return qc;
178
235
  return {
179
236
  ...qc,
180
- takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
237
+ takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
181
238
  };
182
239
  }
183
240
  export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
@@ -206,6 +263,9 @@ export function renderQcTakeHistory(takes) {
206
263
  });
207
264
  return lines;
208
265
  }
266
+ function redoLine(lastRedo) {
267
+ return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
268
+ }
209
269
  function printDisplacedHistory(lines, lastRedo) {
210
270
  const history = lastRedo.displacedHistory;
211
271
  if (!history)
@@ -270,7 +330,9 @@ export const defaultDeps = {
270
330
  dashboardUrl: getDashboardUrl(),
271
331
  };
272
332
  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.";
333
+ "One of three things: that run id doesn't exist on this brand, this command is for admins only " +
334
+ "(director, status --rejected-draft, retry-clip, revoice), or video isn't " +
335
+ "switched on for your dashboard user on this brand. Check the id first, then ask Brad.";
274
336
  export function videoApiError(res) {
275
337
  const behind = missingRouteLine(res, "exodus video");
276
338
  if (behind)
@@ -279,6 +341,9 @@ export function videoApiError(res) {
279
341
  return VIDEO_NOT_FOUND_MESSAGE;
280
342
  return formatApiError(res);
281
343
  }
344
+ function adminOnlyResult(line, json) {
345
+ return { code: 1, lines: json ? [JSON.stringify({ ok: false, error: line })] : [line] };
346
+ }
282
347
  function errorResult(res, json) {
283
348
  return {
284
349
  code: 1,
@@ -301,6 +366,14 @@ function parkedAtFinalWatch(run) {
301
366
  return (!parkedByBuilderCheckpoint(run) &&
302
367
  run.nodes.some((n) => n.kind === "video" && n.status === "done"));
303
368
  }
369
+ function heldReason(run) {
370
+ const verdict = run.storyboardAutoApproval;
371
+ if (verdict?.outcome !== "held")
372
+ return undefined;
373
+ const reason = verdict.reason?.trim() ||
374
+ "The app didn't approve this storyboard by itself because its checks found a problem.";
375
+ return `${reason} (checked ${new Date(verdict.at).toISOString()})`;
376
+ }
304
377
  export function classifyRun(run) {
305
378
  if (run.status === "failed")
306
379
  return { at: "failed", error: run.error };
@@ -320,6 +393,7 @@ export function classifyRun(run) {
320
393
  }
321
394
  const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
322
395
  if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
396
+ const held = heldReason(run);
323
397
  return {
324
398
  at: "storyboard-gate",
325
399
  nodeId: pausedNode.nodeId,
@@ -327,10 +401,31 @@ export function classifyRun(run) {
327
401
  ? { framesNodeId: pausedNode.nodeId }
328
402
  : {}),
329
403
  ...(isShowAd(run) ? { showAd: true } : {}),
404
+ ...(held ? { heldReason: held } : {}),
405
+ };
406
+ }
407
+ if (parkedAtFinalWatch(run)) {
408
+ const videoNodes = run.nodes.filter((n) => n.kind === "video");
409
+ const autoRedoScenes = videoNodes.flatMap((n) => n.autoRedoScenes ?? []);
410
+ const autoRedoReasons = videoNodes.flatMap((n) => n.autoRedoReasons ?? []);
411
+ if (autoRedoScenes.length > 0) {
412
+ return {
413
+ at: "running",
414
+ stage: "video",
415
+ autoRedoScenes,
416
+ ...(autoRedoReasons.length > 0 ? { autoRedoReasons } : {}),
417
+ };
418
+ }
419
+ const missingScenes = run.nodes
420
+ .filter((n) => n.kind === "video")
421
+ .flatMap((n) => n.missingScenes ?? []);
422
+ const framesNodeId = run.nodes.find((n) => n.kind === "scene-frames")?.nodeId;
423
+ return {
424
+ at: "final-watch",
425
+ ...(missingScenes.length > 0 ? { missingScenes } : {}),
426
+ ...(missingScenes.length > 0 && framesNodeId ? { framesNodeId } : {}),
330
427
  };
331
428
  }
332
- if (parkedAtFinalWatch(run))
333
- return { at: "final-watch" };
334
429
  return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
335
430
  }
336
431
  const active = run.nodes.find((n) => n.status === "running");
@@ -339,10 +434,22 @@ export function classifyRun(run) {
339
434
  export function hasAttachedCut(items) {
340
435
  return items.some((i) => i.itemKind === "final" && i.status === "done");
341
436
  }
437
+ export function scenesBeingRedone(run, items) {
438
+ const scenes = new Set(run.nodes.filter((n) => n.kind === "video").flatMap((n) => n.autoRedoScenes ?? []));
439
+ for (const i of items) {
440
+ if (i.itemKind === "clip" &&
441
+ i.status === "running" &&
442
+ (i.attempt ?? 1) > 1 &&
443
+ i.staleClaim !== true) {
444
+ scenes.add(i.sceneIndex);
445
+ }
446
+ }
447
+ return [...scenes].sort((a, b) => a - b);
448
+ }
342
449
  export function resolveStop(stop, cutAttached) {
343
450
  if (stop.at !== "final-watch")
344
451
  return stop;
345
- return { at: "final-watch", cutAttached };
452
+ return { ...stop, cutAttached };
346
453
  }
347
454
  export async function resolveStopAtPark(stop, runId, deps) {
348
455
  if (stop.at !== "final-watch")
@@ -381,15 +488,38 @@ export function isShowAd(run) {
381
488
  return run.moduleOwned === true;
382
489
  }
383
490
  export function reviewUrl(dashboardUrl, run) {
384
- if (isShowAd(run))
385
- return `${dashboardUrl}/video?ad=${run._id}`;
386
491
  if (run.workflowId)
387
492
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
388
493
  return `${dashboardUrl}/runs/${run._id}`;
389
494
  }
495
+ function scenesPhrase(scenes) {
496
+ if (scenes.length === 1)
497
+ return `scene ${scenes[0]}`;
498
+ return `scenes ${scenes.slice(0, -1).join(", ")} and ${scenes[scenes.length - 1]}`;
499
+ }
500
+ function missingSceneLines(missing, framesNodeId, runId, runUrl) {
501
+ const scenes = missing.map((m) => m.sceneIndex);
502
+ const lines = [
503
+ `Parked: ${scenesPhrase(scenes)} ${scenes.length === 1 ? "has" : "have"} no clip yet, so the ad isn't complete.`,
504
+ ];
505
+ for (const { sceneIndex, pictureRefused } of missing) {
506
+ if (!pictureRefused) {
507
+ lines.push(`Redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
508
+ continue;
509
+ }
510
+ 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.`);
511
+ lines.push(framesNodeId
512
+ ? `Redraw frame ${sceneIndex}: exodus video retry-frame ${runId} --node ${framesNodeId} --scene ${sceneIndex} --note "<what to change>"`
513
+ : `Redraw frame ${sceneIndex}: on the run page, ${runUrl}`);
514
+ lines.push(`Then redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
515
+ }
516
+ lines.push("The ad can't be approved until every scene has a clip.", `Watch it here: ${runUrl}`);
517
+ return lines;
518
+ }
390
519
  export function stopLines(stop, runId, runUrl) {
391
520
  if (stop.at === "storyboard-gate") {
392
521
  const lines = [
522
+ ...(stop.heldReason ? [stop.heldReason] : []),
393
523
  "Parked: the storyboard is waiting for your yes.",
394
524
  `Read it: exodus video storyboard ${runId}`,
395
525
  `Approve it: exodus video approve ${runId}`,
@@ -403,6 +533,9 @@ export function stopLines(stop, runId, runUrl) {
403
533
  return lines;
404
534
  }
405
535
  if (stop.at === "final-watch") {
536
+ if (stop.missingScenes?.length) {
537
+ return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
538
+ }
406
539
  if (stop.cutAttached === null) {
407
540
  return [
408
541
  "Parked: every piece is made.",
@@ -458,8 +591,28 @@ export function stopLines(stop, runId, runUrl) {
458
591
  `Open it: ${runUrl}`,
459
592
  ];
460
593
  }
594
+ if (stop.autoRedoScenes?.length)
595
+ return [autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)];
461
596
  return [`Working: ${stageWord(stop.stage)}.`];
462
597
  }
598
+ function autoRedoLine(scenes, reasons = []) {
599
+ const reasonOf = new Map(reasons.map((r) => [r.sceneIndex, r.reason]));
600
+ const groups = new Map();
601
+ for (const scene of [...scenes].sort((a, b) => a - b)) {
602
+ const reason = reasonOf.get(scene);
603
+ groups.set(reason, [...(groups.get(reason) ?? []), scene]);
604
+ }
605
+ const one = scenes.length === 1;
606
+ const clauses = [...groups].map(([reason, group], i) => {
607
+ const own = i === 0 ? ` on ${group.length === 1 ? "its" : "their"} own` : "";
608
+ return `${scenesPhrase(group)}${own}${reason ? ` because ${reason}` : ""}`;
609
+ });
610
+ const listed = clauses.length === 1
611
+ ? clauses[0]
612
+ : `${clauses.slice(0, -1).join(", ")}, and ${clauses[clauses.length - 1]}`;
613
+ return (`Working: the app is redoing ${listed}. ` +
614
+ `The ad isn't ready to watch until ${one ? "it lands" : "they land"}.`);
615
+ }
463
616
  export function failedStoryboardNode(run) {
464
617
  return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
465
618
  }
@@ -484,7 +637,13 @@ export function planFailureLines(node, runId) {
484
637
  }
485
638
  return lines;
486
639
  }
640
+ export const MUSIC_HEARD_CODE = "music-heard";
487
641
  export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
642
+ export function musicBedState(run) {
643
+ if (run.musicBed === undefined)
644
+ return "unknown";
645
+ return run.musicBed ? "on" : "off";
646
+ }
488
647
  function isPullSceneIndex(sceneIndex) {
489
648
  return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
490
649
  }
@@ -526,6 +685,10 @@ function clipFromArtifact(sceneIndex, artifact) {
526
685
  revoiced: artifact.revoiced === true,
527
686
  speechTrimmed: artifact.speechTrimmed === true,
528
687
  rawStorageId: artifact.rawStorageId ?? null,
688
+ voiceMode: artifact.voiceMode ?? null,
689
+ voiceFirstBlockedBy: artifact.voiceFirstBlockedBy ?? null,
690
+ voicePitchHz: artifact.voicePitchHz ?? null,
691
+ voicePitchMethod: artifact.voicePitchMethod ?? null,
529
692
  };
530
693
  }
531
694
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -677,14 +840,36 @@ export function planPull(run, items, opts) {
677
840
  }
678
841
  }
679
842
  let reference = null;
843
+ let productSheet = null;
844
+ let setPlate = null;
845
+ const filledByStage = new Map();
680
846
  for (const artifact of outputsOfNodeKind(run, "reference")) {
681
847
  if (artifact.type !== "image" || !artifact.imageUrl)
682
848
  continue;
683
- reference = {
684
- file: `reference.${extFor(artifact.imageUrl, "image")}`,
685
- url: artifact.imageUrl,
686
- };
687
- }
849
+ if (artifact.anchor === "filled-plate") {
850
+ const holds = artifact.holds ?? [];
851
+ const lid = artifact.lid;
852
+ filledByStage.set(JSON.stringify([holds, lid ?? null]), { holds, url: artifact.imageUrl, ...(lid ? { lid } : {}) });
853
+ continue;
854
+ }
855
+ const stem = artifact.anchor ?? "reference";
856
+ const download = { file: `${stem}.${extFor(artifact.imageUrl, "image")}`, url: artifact.imageUrl };
857
+ if (artifact.anchor === "product-sheet")
858
+ productSheet = download;
859
+ else if (artifact.anchor === "set-plate")
860
+ setPlate = download;
861
+ else if (!artifact.anchor)
862
+ reference = download;
863
+ }
864
+ const stageKeys = [...new Set([...filledByStage.values()].map((plate) => JSON.stringify(plate.holds)))];
865
+ const filledPlates = [...filledByStage.values()].map((plate) => ({
866
+ holds: plate.holds,
867
+ ...(plate.lid ? { lid: plate.lid } : {}),
868
+ download: {
869
+ file: `filled-plate-${stageKeys.indexOf(JSON.stringify(plate.holds)) + 1}${plate.lid ? "-lid-closed" : ""}.${extFor(plate.url, "image")}`,
870
+ url: plate.url,
871
+ },
872
+ }));
688
873
  const keyframeByScene = new Map();
689
874
  for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
690
875
  if (artifact.type !== "frames")
@@ -787,6 +972,9 @@ export function planPull(run, items, opts) {
787
972
  continue;
788
973
  keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
789
974
  }
975
+ const pendingRedo = scenesBeingRedone(run, items);
976
+ for (const sceneIndex of pendingRedo)
977
+ clipByScene.delete(sceneIndex);
790
978
  const sceneIndexes = [
791
979
  ...new Set([
792
980
  ...clipByScene.keys(),
@@ -835,6 +1023,10 @@ export function planPull(run, items, opts) {
835
1023
  revoiced: clip ? clip.revoiced : null,
836
1024
  speechTrimmed: clip ? clip.speechTrimmed : null,
837
1025
  rawStorageId: clip ? clip.rawStorageId : null,
1026
+ voiceMode: clip ? clip.voiceMode : null,
1027
+ voiceFirstBlockedBy: clip ? clip.voiceFirstBlockedBy : null,
1028
+ voicePitchHz: clip ? clip.voicePitchHz : null,
1029
+ voicePitchMethod: clip ? clip.voicePitchMethod : null,
838
1030
  clipStatus: item?.status ?? "missing",
839
1031
  error: item?.error ?? null,
840
1032
  flagged: item?.flagged === true,
@@ -845,6 +1037,9 @@ export function planPull(run, items, opts) {
845
1037
  const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
846
1038
  const downloads = [
847
1039
  ...(reference ? [reference] : []),
1040
+ ...(productSheet ? [productSheet] : []),
1041
+ ...(setPlate ? [setPlate] : []),
1042
+ ...filledPlates.map((p) => p.download),
848
1043
  ...keyframeByScene.values(),
849
1044
  ...[...voiceByScene.values()].map((v) => v.download),
850
1045
  ...(narrationDownload ? [narrationDownload] : []),
@@ -861,7 +1056,15 @@ export function planPull(run, items, opts) {
861
1056
  dashboardUrl: reviewUrl(opts.dashboardUrl, run),
862
1057
  storyboard,
863
1058
  reference: reference?.file ?? null,
1059
+ productSheet: productSheet?.file ?? null,
1060
+ setPlate: setPlate?.file ?? null,
1061
+ filledPlates: filledPlates.map((p) => ({ file: p.download.file, holds: p.holds, ...(p.lid ? { lid: p.lid } : {}) })),
864
1062
  music: music?.file ?? null,
1063
+ musicBed: musicBedState(run),
1064
+ musicHeardScenes: scenes
1065
+ .filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
1066
+ .map((scene) => scene.sceneIndex),
1067
+ pendingRedo,
865
1068
  cast,
866
1069
  narration: narrationDownload
867
1070
  ? { file: narrationDownload.file, timing: "narration.json" }
@@ -871,6 +1074,15 @@ export function planPull(run, items, opts) {
871
1074
  ...(run.provenance?.format || run.provenance?.voice
872
1075
  ? { provenance: run.provenance }
873
1076
  : {}),
1077
+ ...(run.videoChoice
1078
+ ? {
1079
+ videoChoice: {
1080
+ videoModel: run.videoChoice.videoModel,
1081
+ voiceMode: run.videoChoice.voiceMode,
1082
+ },
1083
+ }
1084
+ : {}),
1085
+ ...(run.shots?.frame || run.shots?.pacing ? { shots: run.shots } : {}),
874
1086
  },
875
1087
  };
876
1088
  }
@@ -880,6 +1092,14 @@ export function markPullFailure(manifest, failure) {
880
1092
  manifest.storyboard = null;
881
1093
  if (manifest.reference === failure.file)
882
1094
  manifest.reference = null;
1095
+ if (manifest.productSheet === failure.file)
1096
+ manifest.productSheet = null;
1097
+ if (manifest.setPlate === failure.file)
1098
+ manifest.setPlate = null;
1099
+ for (const plate of manifest.filledPlates) {
1100
+ if (plate.file === failure.file)
1101
+ plate.file = null;
1102
+ }
883
1103
  if (manifest.music === failure.file)
884
1104
  manifest.music = null;
885
1105
  for (const ref of manifest.cast) {
@@ -896,6 +1116,10 @@ export function markPullFailure(manifest, failure) {
896
1116
  scene.revoiced = null;
897
1117
  scene.speechTrimmed = null;
898
1118
  scene.rawStorageId = null;
1119
+ scene.voiceMode = null;
1120
+ scene.voiceFirstBlockedBy = null;
1121
+ scene.voicePitchHz = null;
1122
+ scene.voicePitchMethod = null;
899
1123
  }
900
1124
  if (scene.voice === failure.file)
901
1125
  scene.voice = null;
@@ -1001,7 +1225,7 @@ export async function startFlow(opts, deps) {
1001
1225
  showId: opts.showId,
1002
1226
  script,
1003
1227
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1004
- ...(opts.music === false ? { music: false } : {}),
1228
+ ...(opts.music === undefined ? {} : { music: opts.music }),
1005
1229
  });
1006
1230
  if (!res.ok)
1007
1231
  return errorResult(res, opts.json);
@@ -1035,10 +1259,48 @@ function nonBlank(value) {
1035
1259
  return undefined;
1036
1260
  return value;
1037
1261
  }
1038
- function isConceit(value) {
1039
- return CONCEIT_KEYS.some((key) => key === value);
1262
+ function isVoiceMode(value) {
1263
+ return VOICE_MODES.some((mode) => mode === value);
1264
+ }
1265
+ export function planMusicChoice(occurrences) {
1266
+ let music;
1267
+ for (const { flag, value } of occurrences) {
1268
+ if (flag === "no-music") {
1269
+ music = false;
1270
+ continue;
1271
+ }
1272
+ if (flag !== "music")
1273
+ continue;
1274
+ if (value === undefined || value.startsWith("--")) {
1275
+ music = true;
1276
+ continue;
1277
+ }
1278
+ const word = value.trim().toLowerCase();
1279
+ const literal = word === "true" ? true : word === "false" ? false : undefined;
1280
+ if (literal === undefined) {
1281
+ return {
1282
+ ok: false,
1283
+ line: `video start --music takes true or false, or no value at all (got "${value}").`,
1284
+ };
1285
+ }
1286
+ music = literal;
1287
+ }
1288
+ return { ok: true, music };
1289
+ }
1290
+ function planValueFlag(occurrences, name) {
1291
+ let value;
1292
+ for (const occurrence of occurrences) {
1293
+ if (occurrence.flag !== name)
1294
+ continue;
1295
+ const given = occurrence.value;
1296
+ if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
1297
+ return { ok: false, line: `video start --${name} needs a value.` };
1298
+ }
1299
+ value = given.trim();
1300
+ }
1301
+ return { ok: true, value };
1040
1302
  }
1041
- export function planVideoStart(flags) {
1303
+ export function planVideoStart(flags, occurrences) {
1042
1304
  const showId = nonBlank(flagString(flags, "show"));
1043
1305
  const scriptFile = nonBlank(flagString(flags, "script"));
1044
1306
  const conceit = nonBlank(flagString(flags, "conceit"));
@@ -1055,6 +1317,62 @@ export function planVideoStart(flags) {
1055
1317
  line: "video start needs --script <file>, a text file of what the ad says.",
1056
1318
  };
1057
1319
  }
1320
+ const musicChoice = planMusicChoice(occurrences);
1321
+ if (!musicChoice.ok)
1322
+ return { kind: "usage", line: musicChoice.line };
1323
+ const videoModelFlag = planValueFlag(occurrences, "video-model");
1324
+ if (!videoModelFlag.ok)
1325
+ return { kind: "usage", line: videoModelFlag.line };
1326
+ const voiceFlag = planValueFlag(occurrences, "voice");
1327
+ if (!voiceFlag.ok)
1328
+ return { kind: "usage", line: voiceFlag.line };
1329
+ const frameFlag = planValueFlag(occurrences, "frame");
1330
+ if (!frameFlag.ok)
1331
+ return { kind: "usage", line: frameFlag.line };
1332
+ const pacingFlag = planValueFlag(occurrences, "pacing");
1333
+ if (!pacingFlag.ok)
1334
+ return { kind: "usage", line: pacingFlag.line };
1335
+ const videoModel = videoModelFlag.value;
1336
+ const voiceMode = voiceFlag.value;
1337
+ const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
1338
+ if (showId && reviewStoryboard) {
1339
+ return {
1340
+ kind: "usage",
1341
+ line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
1342
+ };
1343
+ }
1344
+ const directorFlags = occurrences.filter((o) => o.flag === "director");
1345
+ if (directorFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1346
+ return {
1347
+ kind: "usage",
1348
+ line: "--director takes no value. Leave it off to start without the director.",
1349
+ };
1350
+ }
1351
+ const director = directorFlags.length > 0;
1352
+ if (showId && director) {
1353
+ return {
1354
+ kind: "usage",
1355
+ line: "--director works with --conceit, not --show.",
1356
+ };
1357
+ }
1358
+ if (showId && (frameFlag.value || pacingFlag.value)) {
1359
+ return {
1360
+ kind: "usage",
1361
+ line: "--frame and --pacing work with --conceit, not --show.",
1362
+ };
1363
+ }
1364
+ if (showId && (videoModel || voiceMode)) {
1365
+ return {
1366
+ kind: "usage",
1367
+ line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
1368
+ };
1369
+ }
1370
+ if (voiceMode && !isVoiceMode(voiceMode)) {
1371
+ return {
1372
+ kind: "usage",
1373
+ line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
1374
+ };
1375
+ }
1058
1376
  if (showId) {
1059
1377
  return {
1060
1378
  kind: "show",
@@ -1062,7 +1380,7 @@ export function planVideoStart(flags) {
1062
1380
  showId,
1063
1381
  scriptFile,
1064
1382
  voicePath: flagString(flags, "voice-path"),
1065
- music: flags["music"] === false ? false : undefined,
1383
+ music: musicChoice.music,
1066
1384
  wait: flags["wait"] === true,
1067
1385
  json: flags["json"] === true,
1068
1386
  },
@@ -1071,19 +1389,13 @@ export function planVideoStart(flags) {
1071
1389
  if (!conceit) {
1072
1390
  return {
1073
1391
  kind: "usage",
1074
- line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
1075
- };
1076
- }
1077
- if (!isConceit(conceit)) {
1078
- return {
1079
- kind: "usage",
1080
- line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
1392
+ line: "video start needs --conceit <kind>.",
1081
1393
  };
1082
1394
  }
1083
1395
  if (!style) {
1084
1396
  return {
1085
1397
  kind: "usage",
1086
- line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
1398
+ line: "video start needs --style <look>.",
1087
1399
  };
1088
1400
  }
1089
1401
  const direction = nonBlank(flagString(flags, "direction"));
@@ -1099,13 +1411,45 @@ export function planVideoStart(flags) {
1099
1411
  opts.direction = direction;
1100
1412
  if (voicePath)
1101
1413
  opts.voicePath = voicePath;
1102
- if (flags["music"] === false)
1103
- opts.music = false;
1414
+ if (videoModel)
1415
+ opts.videoModel = videoModel;
1416
+ if (voiceMode && isVoiceMode(voiceMode))
1417
+ opts.voiceMode = voiceMode;
1418
+ if (musicChoice.music !== undefined)
1419
+ opts.music = musicChoice.music;
1420
+ if (reviewStoryboard)
1421
+ opts.reviewStoryboard = true;
1422
+ if (director)
1423
+ opts.director = true;
1424
+ if (frameFlag.value || pacingFlag.value) {
1425
+ opts.shots = {
1426
+ ...(frameFlag.value ? { frame: frameFlag.value } : {}),
1427
+ ...(pacingFlag.value ? { pacing: pacingFlag.value } : {}),
1428
+ };
1429
+ }
1104
1430
  return { kind: "script", opts };
1105
1431
  }
1106
1432
  function isRecord(value) {
1107
1433
  return typeof value === "object" && value !== null && !Array.isArray(value);
1108
1434
  }
1435
+ function scriptRunOptionsOf(data) {
1436
+ if (!isRecord(data) || !Array.isArray(data.conceits) || !Array.isArray(data.styles)) {
1437
+ return null;
1438
+ }
1439
+ const conceits = [];
1440
+ for (const row of data.conceits) {
1441
+ if (!isRecord(row) || typeof row.key !== "string" || typeof row.label !== "string")
1442
+ return null;
1443
+ conceits.push({ key: row.key, label: row.label });
1444
+ }
1445
+ const styles = [];
1446
+ for (const row of data.styles) {
1447
+ if (!isRecord(row) || typeof row.slug !== "string" || typeof row.label !== "string")
1448
+ return null;
1449
+ styles.push({ slug: row.slug, label: row.label });
1450
+ }
1451
+ return { conceits, styles };
1452
+ }
1109
1453
  function isResolvedCard(value) {
1110
1454
  if (!isRecord(value))
1111
1455
  return false;
@@ -1123,7 +1467,14 @@ function isResolvedCard(value) {
1123
1467
  return false;
1124
1468
  return true;
1125
1469
  }
1126
- function scriptStartedLines(runId, url, card) {
1470
+ function startedVideoChoice(value) {
1471
+ if (!isRecord(value))
1472
+ return null;
1473
+ if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
1474
+ return null;
1475
+ return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1476
+ }
1477
+ function scriptStartedLines(runId, url, card, video, director) {
1127
1478
  return [
1128
1479
  `Started ad run ${runId}`,
1129
1480
  ...(card
@@ -1134,10 +1485,12 @@ function scriptStartedLines(runId, url, card) {
1134
1485
  `Style: ${card.style.label}`,
1135
1486
  ]
1136
1487
  : ["The server did not include a resolved card."]),
1488
+ ...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
1489
+ ...(director ? ["Director: on for this ad"] : []),
1137
1490
  `Watch it: ${url}`,
1138
1491
  ];
1139
1492
  }
1140
- function waitJsonWithCard(waited, card) {
1493
+ function waitJsonWithCard(waited, card, video) {
1141
1494
  const first = waited.lines[0];
1142
1495
  if (typeof first !== "string")
1143
1496
  return waited;
@@ -1150,7 +1503,10 @@ function waitJsonWithCard(waited, card) {
1150
1503
  }
1151
1504
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1152
1505
  return waited;
1153
- return { code: waited.code, lines: [JSON.stringify({ ...parsed, card })] };
1506
+ return {
1507
+ code: waited.code,
1508
+ lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1509
+ };
1154
1510
  }
1155
1511
  export async function startScriptFlow(opts, deps) {
1156
1512
  let script;
@@ -1168,16 +1524,35 @@ export async function startScriptFlow(opts, deps) {
1168
1524
  if (script.trim().length === 0) {
1169
1525
  return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
1170
1526
  }
1527
+ const listed = await deps.get(SCRIPT_RUNS_PATH).catch(() => null);
1528
+ if (listed?.ok) {
1529
+ const options = scriptRunOptionsOf(listed.data);
1530
+ if (!options) {
1531
+ return adminOnlyResult("The server's list of kinds and looks could not be read.", opts.json);
1532
+ }
1533
+ const problem = scriptRunPickProblem(options, opts.conceit, opts.style);
1534
+ if (problem) {
1535
+ return opts.json ? adminOnlyResult(problem, true) : { code: 1, lines: [problem], usage: true };
1536
+ }
1537
+ }
1171
1538
  const res = await deps.post(SCRIPT_RUNS_PATH, {
1172
1539
  script,
1173
1540
  conceit: opts.conceit,
1174
1541
  style: opts.style,
1175
1542
  ...(opts.direction ? { direction: opts.direction } : {}),
1176
1543
  ...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
1177
- ...(opts.music === false ? { music: false } : {}),
1544
+ ...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
1545
+ ...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
1546
+ ...(opts.music === true ? { music: true } : {}),
1547
+ ...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
1548
+ ...(opts.director ? { director: true } : {}),
1549
+ ...(opts.shots ? { shots: opts.shots } : {}),
1178
1550
  });
1179
1551
  if (!res.ok)
1180
1552
  return errorResult(res, opts.json);
1553
+ const customerStart = customerAnswer(res.data);
1554
+ if (customerStart)
1555
+ return customerStartedResult(customerStart, opts, deps);
1181
1556
  const started = res.data;
1182
1557
  if (!started.runId) {
1183
1558
  return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
@@ -1185,25 +1560,52 @@ export async function startScriptFlow(opts, deps) {
1185
1560
  const runId = started.runId;
1186
1561
  const url = typeof started.url === "string" ? started.url : "";
1187
1562
  const card = isResolvedCard(started.card) ? started.card : null;
1563
+ const video = startedVideoChoice(started.videoChoice);
1188
1564
  if (!opts.wait) {
1189
1565
  return {
1190
1566
  code: 0,
1191
1567
  lines: opts.json
1192
- ? [JSON.stringify({ runId, url, card })]
1568
+ ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1193
1569
  : [
1194
- ...scriptStartedLines(runId, url, card),
1570
+ ...scriptStartedLines(runId, url, card, video, opts.director === true),
1195
1571
  "",
1196
- "Wait for the storyboard here instead: exodus video start … --wait",
1572
+ ...(opts.reviewStoryboard
1573
+ ? ["Wait for the storyboard here instead: exodus video start … --wait"]
1574
+ : [
1575
+ "The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
1576
+ "Wait here until it needs you: exodus video start … --wait",
1577
+ ]),
1197
1578
  `Or check in whenever: exodus video status ${runId}`,
1198
1579
  ],
1199
1580
  };
1200
1581
  }
1201
1582
  const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1202
1583
  if (opts.json)
1203
- return waitJsonWithCard(waited, card);
1584
+ return waitJsonWithCard(waited, card, video);
1204
1585
  return {
1205
1586
  code: waited.code,
1206
- lines: [...scriptStartedLines(runId, url, card), "", ...waited.lines],
1587
+ lines: [...scriptStartedLines(runId, url, card, video, opts.director === true), "", ...waited.lines],
1588
+ };
1589
+ }
1590
+ async function customerStartedResult(started, opts, deps) {
1591
+ const { runId, dashboardUrl } = started;
1592
+ const head = [`Started video ${runId}`, `Watch it: ${dashboardUrl}`];
1593
+ if (opts.wait) {
1594
+ const waited = await waitFlow(runId, { json: opts.json, url: dashboardUrl }, deps);
1595
+ return opts.json ? waited : { code: waited.code, lines: [...head, "", ...waited.lines] };
1596
+ }
1597
+ if (opts.json)
1598
+ return { code: 0, lines: [JSON.stringify(started)] };
1599
+ return {
1600
+ code: 0,
1601
+ lines: [
1602
+ ...head,
1603
+ "",
1604
+ opts.reviewStoryboard
1605
+ ? "It stops at the storyboard for you to approve."
1606
+ : "The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
1607
+ `Check in whenever: exodus video status ${runId}`,
1608
+ ],
1207
1609
  };
1208
1610
  }
1209
1611
  const POLL_INTERVAL_MS = 5000;
@@ -1212,7 +1614,8 @@ export async function waitFlow(runId, opts, deps) {
1212
1614
  const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
1213
1615
  const maxPolls = opts.maxPolls ?? MAX_POLLS;
1214
1616
  const lines = [];
1215
- let lastStage = null;
1617
+ let lastProgress = null;
1618
+ let redoSeen = false;
1216
1619
  for (let poll = 0; poll < maxPolls; poll++) {
1217
1620
  if (poll > 0)
1218
1621
  await deps.sleep(interval);
@@ -1221,17 +1624,57 @@ export async function waitFlow(runId, opts, deps) {
1221
1624
  const failed = errorResult(res, opts.json);
1222
1625
  return { code: failed.code, lines: [...lines, ...failed.lines] };
1223
1626
  }
1627
+ const customer = customerAnswer(res.data);
1628
+ if (customer) {
1629
+ const page = customerRunPage(deps.dashboardUrl, runId);
1630
+ if (customerStageIsWorking(customer.stage)) {
1631
+ const progress = customerStageLines(customer, page)[0];
1632
+ if (progress !== lastProgress) {
1633
+ lastProgress = progress;
1634
+ if (!opts.json)
1635
+ lines.push(progress);
1636
+ }
1637
+ continue;
1638
+ }
1639
+ return {
1640
+ code: customer.stage === "failed" ? 1 : 0,
1641
+ lines: opts.json
1642
+ ? [JSON.stringify(customer)]
1643
+ : [...lines, ...customerStageLines(customer, page), `Watch it on the dashboard: ${page}`],
1644
+ };
1645
+ }
1224
1646
  const run = asVideoRun(res.data);
1225
1647
  const stop = classifyRun(run);
1226
1648
  if (stop.at === "running") {
1227
- if (stop.stage !== lastStage) {
1228
- lastStage = stop.stage;
1649
+ const progress = stop.autoRedoScenes?.length
1650
+ ? autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)
1651
+ : `Working: ${stageWord(stop.stage)}…`;
1652
+ if (progress !== lastProgress) {
1653
+ lastProgress = progress;
1229
1654
  if (!opts.json)
1230
- lines.push(`Working: ${stageWord(stop.stage)}…`);
1655
+ lines.push(progress);
1231
1656
  }
1232
1657
  continue;
1233
1658
  }
1234
- const resolved = await resolveStopAtPark(stop, runId, deps);
1659
+ let items = null;
1660
+ if (stop.at === "final-watch") {
1661
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1662
+ items = itemsRes.ok ? (itemsRes.data.items ?? []) : null;
1663
+ if (items === null && redoSeen)
1664
+ continue;
1665
+ const redoing = items ? scenesBeingRedone(run, items) : [];
1666
+ redoSeen = redoing.length > 0;
1667
+ if (redoSeen) {
1668
+ const progress = `Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`;
1669
+ if (progress !== lastProgress) {
1670
+ lastProgress = progress;
1671
+ if (!opts.json)
1672
+ lines.push(progress);
1673
+ }
1674
+ continue;
1675
+ }
1676
+ }
1677
+ const resolved = resolveStop(stop, items ? hasAttachedCut(items) : null);
1235
1678
  if (opts.json) {
1236
1679
  return {
1237
1680
  code: stop.at === "failed" ? 1 : 0,
@@ -1274,6 +1717,13 @@ export async function statusFlow(runId, json, deps) {
1274
1717
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1275
1718
  if (!runRes.ok)
1276
1719
  return errorResult(runRes, json);
1720
+ const customer = customerAnswer(runRes.data);
1721
+ if (customer) {
1722
+ return {
1723
+ code: 0,
1724
+ lines: json ? [JSON.stringify(customer)] : customerStatusLines(customer, deps.dashboardUrl),
1725
+ };
1726
+ }
1277
1727
  const run = asVideoRun(runRes.data);
1278
1728
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1279
1729
  if (!itemsRes.ok)
@@ -1300,18 +1750,32 @@ export async function statusFlow(runId, json, deps) {
1300
1750
  hasFinal,
1301
1751
  warnings,
1302
1752
  guidance,
1753
+ ...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
1303
1754
  ...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
1304
1755
  ...(failedStoryboard?.planFailure
1305
1756
  ? { storyboardNodeId: failedStoryboard.nodeId }
1306
1757
  : {}),
1307
1758
  ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1759
+ ...(run.director ? { director: run.director } : {}),
1308
1760
  }),
1309
1761
  ],
1310
1762
  };
1311
1763
  }
1312
1764
  const revoicedByScene = new Set();
1765
+ const voiceFirstByScene = new Set();
1766
+ const voiceFirstBlockedByScene = new Map();
1313
1767
  const timedOnOriginalVoice = new Set();
1314
1768
  for (const artifact of outputsOfNodeKind(run, "video")) {
1769
+ if (artifact.type === "video" &&
1770
+ artifact.voiceMode === "voice-first" &&
1771
+ typeof artifact.sceneIndex === "number") {
1772
+ voiceFirstByScene.add(artifact.sceneIndex);
1773
+ }
1774
+ if (artifact.type === "video" &&
1775
+ artifact.voiceFirstBlockedBy?.length &&
1776
+ typeof artifact.sceneIndex === "number") {
1777
+ voiceFirstBlockedByScene.set(artifact.sceneIndex, artifact.voiceFirstBlockedBy);
1778
+ }
1315
1779
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
1316
1780
  revoicedByScene.add(artifact.sceneIndex);
1317
1781
  if ((artifact.words?.length ?? 0) > 0)
@@ -1342,6 +1806,7 @@ export async function statusFlow(runId, json, deps) {
1342
1806
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1343
1807
  ...planFailureLines(failedStoryboard, runId),
1344
1808
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
1809
+ ...(run.director ? ["", ...directorLines(run.director)] : []),
1345
1810
  ];
1346
1811
  if (byScene.size === 0) {
1347
1812
  lines.push("", "No scenes yet — this run hasn't made anything to look at.");
@@ -1352,7 +1817,7 @@ export async function statusFlow(runId, json, deps) {
1352
1817
  const row = byScene.get(sceneIndex);
1353
1818
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1354
1819
  if (row.clip?.lastRedo) {
1355
- lines.push(` clip: ${row.clip.lastRedo.label}`);
1820
+ lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
1356
1821
  printDisplacedHistory(lines, row.clip.lastRedo);
1357
1822
  }
1358
1823
  if (row.clip?.lastRedo || row.clip?.flagged) {
@@ -1361,7 +1826,7 @@ export async function statusFlow(runId, json, deps) {
1361
1826
  }
1362
1827
  }
1363
1828
  if (row.voiceover?.lastRedo) {
1364
- lines.push(` voice: ${row.voiceover.lastRedo.label}`);
1829
+ lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
1365
1830
  printDisplacedHistory(lines, row.voiceover.lastRedo);
1366
1831
  }
1367
1832
  if (row.frame?.lastRedo)
@@ -1380,6 +1845,12 @@ export async function statusFlow(runId, json, deps) {
1380
1845
  lines.push(` ${row.clip.error}`);
1381
1846
  if (revoicedByScene.has(sceneIndex))
1382
1847
  lines.push(" voice: cast voice applied");
1848
+ if (voiceFirstByScene.has(sceneIndex))
1849
+ lines.push(" clip: redone voice first");
1850
+ const blockedBy = voiceFirstBlockedByScene.get(sceneIndex);
1851
+ if (blockedBy) {
1852
+ lines.push(` clip: redone the usual way — ${cantSayNames(blockedBy)}`);
1853
+ }
1383
1854
  if (timedOnOriginalVoice.has(sceneIndex)) {
1384
1855
  lines.push(" word timings: measured on the original voice (close, not frame-exact)");
1385
1856
  }
@@ -1398,10 +1869,79 @@ export async function statusFlow(runId, json, deps) {
1398
1869
  : "Final cut: not uploaded yet.");
1399
1870
  return { code: 0, lines };
1400
1871
  }
1872
+ const DIRECTOR_STATUS_WORDS = {
1873
+ reviewing: "Watching this run",
1874
+ waiting: "Waiting for redos to finish",
1875
+ done: "Done: ready for your approval",
1876
+ stopped: "Stopped: see the last note",
1877
+ };
1878
+ const DIRECTOR_PHASE_WORDS = {
1879
+ storyboard: "Storyboard",
1880
+ "final-watch": "Final watch",
1881
+ };
1882
+ const DIRECTOR_LOG_LINES = 3;
1883
+ export function directorLines(director) {
1884
+ const switchWords = director.runSetting === null
1885
+ ? `following the brand's setting (${director.workspaceOn ? "on" : "off"})`
1886
+ : "switched for this ad";
1887
+ const log = director.log.slice(-DIRECTOR_LOG_LINES);
1888
+ return [
1889
+ `Director: ${director.on ? "on" : "off"}, ${switchWords}`,
1890
+ ...(director.on
1891
+ ? [` ${DIRECTOR_STATUS_WORDS[director.status ?? "reviewing"]}`]
1892
+ : []),
1893
+ ` Redos: ${director.redosUsed} of ${director.redoCap}. Spent: $${director.spendUsd.toFixed(2)} of $${director.spendCapUsd.toFixed(2)}`,
1894
+ ...(log.length > 0
1895
+ ? [
1896
+ " Latest notes:",
1897
+ ...log.map((entry) => ` ${DIRECTOR_PHASE_WORDS[entry.phase]}: ${entry.text}`),
1898
+ ]
1899
+ : []),
1900
+ ];
1901
+ }
1902
+ export function parseDirectorSwitch(word) {
1903
+ if (word === "on")
1904
+ return { on: true };
1905
+ if (word === "off")
1906
+ return { on: false };
1907
+ if (word === "follow")
1908
+ return { on: null };
1909
+ return null;
1910
+ }
1911
+ export async function directorFlow(runId, on, json, deps) {
1912
+ const res = await deps.post(DIRECTOR_PATH, { runId, on });
1913
+ const behind = missingRouteLine(res, "exodus video director");
1914
+ if (behind) {
1915
+ return {
1916
+ code: 1,
1917
+ lines: json ? [JSON.stringify({ ok: false, status: 404, error: behind })] : [behind],
1918
+ };
1919
+ }
1920
+ if (!res.ok)
1921
+ return errorResult(res, json);
1922
+ if (json)
1923
+ return { code: 0, lines: [JSON.stringify({ runId, on })] };
1924
+ const said = on === null
1925
+ ? `The director on ad run ${runId} now follows the brand's own setting.`
1926
+ : `The director is ${on ? "on" : "off"} for ad run ${runId}.`;
1927
+ return {
1928
+ code: 0,
1929
+ lines: [
1930
+ said,
1931
+ ...(on === true
1932
+ ? ["If the ad is already waiting at the storyboard or the final watch, a review starts now."]
1933
+ : []),
1934
+ `See what it is doing: exodus video status ${runId}`,
1935
+ ],
1936
+ };
1937
+ }
1401
1938
  export async function rejectedDraftFlow(runId, json, deps) {
1402
1939
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1403
1940
  if (!runRes.ok)
1404
1941
  return errorResult(runRes, json);
1942
+ if (customerAnswer(runRes.data)) {
1943
+ return adminOnlyResult("exodus video status --rejected-draft is for admins.", json);
1944
+ }
1405
1945
  const node = failedStoryboardNode(asVideoRun(runRes.data));
1406
1946
  if (!node) {
1407
1947
  const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
@@ -1441,6 +1981,10 @@ export async function storyboardFlow(runId, json, deps) {
1441
1981
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
1442
1982
  if (!res.ok)
1443
1983
  return errorResult(res, json);
1984
+ const board = customerAnswer(res.data);
1985
+ if (board) {
1986
+ return { code: 0, lines: json ? [JSON.stringify(board)] : customerStoryboardLines(board) };
1987
+ }
1444
1988
  const cards = res.data;
1445
1989
  if (json)
1446
1990
  return { code: 0, lines: [JSON.stringify(cards)] };
@@ -1579,14 +2123,34 @@ export async function flagFlow(runId, note, json, deps) {
1579
2123
  };
1580
2124
  }
1581
2125
  export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps) {
2126
+ if (nodeId === undefined) {
2127
+ const board = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
2128
+ if (!board.ok)
2129
+ return errorResult(board, json);
2130
+ if (!customerAnswer(board.data)) {
2131
+ return { code: 1, lines: ["video retry-frame needs --node <nodeId>."] };
2132
+ }
2133
+ }
1582
2134
  const res = await deps.post(SCENE_RETRY_PATH, {
1583
2135
  runId,
1584
- nodeId,
2136
+ ...(nodeId === undefined ? {} : { nodeId }),
1585
2137
  sceneIndex,
1586
2138
  ...(note ? { note } : {}),
1587
2139
  });
1588
2140
  if (!res.ok)
1589
2141
  return errorResult(res, json);
2142
+ if (customerAnswer(res.data)) {
2143
+ if (json)
2144
+ return { code: 0, lines: [JSON.stringify({ ok: true, runId, sceneIndex })] };
2145
+ return {
2146
+ code: 0,
2147
+ lines: [
2148
+ `Redrawing scene ${sceneIndex}'s picture. The other scenes stay as they are.`,
2149
+ ...(note ? ["Your note isn't used on this redo."] : []),
2150
+ `See it when it's done: exodus video storyboard ${runId}`,
2151
+ ],
2152
+ };
2153
+ }
1590
2154
  const triggerRunId = res.data.triggerRunId;
1591
2155
  if (json) {
1592
2156
  return {
@@ -1623,7 +2187,10 @@ export function planClipRedo(run, items, target) {
1623
2187
  const { row, uploadedCut } = found;
1624
2188
  const redoable = row.status === "failed" ||
1625
2189
  row.staleClaim === true ||
1626
- (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
2190
+ (row.status === "done" &&
2191
+ (row.flagged === true ||
2192
+ stop.at === "final-watch" ||
2193
+ (stop.at === "running" && stop.autoRedoScenes !== undefined)));
1627
2194
  if (!redoable) {
1628
2195
  if (row.status === "done") {
1629
2196
  return {
@@ -1680,10 +2247,14 @@ function findClipRow(run, items, target) {
1680
2247
  }
1681
2248
  return { row, uploadedCut };
1682
2249
  }
1683
- export async function retryClipFlow(runId, target, note, json, deps) {
2250
+ export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
1684
2251
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1685
2252
  if (!runRes.ok)
1686
2253
  return errorResult(runRes, json);
2254
+ if (customerAnswer(runRes.data)) {
2255
+ return adminOnlyResult("Redoing a finished clip is for admins. To redo a scene's picture while your storyboard " +
2256
+ `waits for you: exodus video retry-frame ${runId} --scene <n>`, json);
2257
+ }
1687
2258
  const run = asVideoRun(runRes.data);
1688
2259
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1689
2260
  if (!itemsRes.ok)
@@ -1696,12 +2267,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1696
2267
  lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1697
2268
  };
1698
2269
  }
1699
- const res = await deps.post(SCENE_RETRY_PATH, {
2270
+ const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
1700
2271
  runId,
1701
2272
  nodeId: plan.nodeId,
1702
2273
  sceneIndex: plan.sceneIndex,
1703
2274
  ...(note ? { note } : {}),
1704
2275
  });
2276
+ if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
2277
+ return {
2278
+ code: 1,
2279
+ lines: json
2280
+ ? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
2281
+ : [VOICE_FIRST_NOT_ON_THIS_SERVER],
2282
+ };
2283
+ }
1705
2284
  if (!res.ok)
1706
2285
  return errorResult(res, json);
1707
2286
  const triggerRunId = res.data.triggerRunId;
@@ -1720,6 +2299,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1720
2299
  reviewUrl: review,
1721
2300
  warnings: plan.warnings,
1722
2301
  ...(note ? { note } : {}),
2302
+ ...(voiceFirst ? { voiceMode: "voice-first" } : {}),
1723
2303
  }),
1724
2304
  ],
1725
2305
  };
@@ -1727,9 +2307,19 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1727
2307
  return {
1728
2308
  code: 0,
1729
2309
  lines: [
1730
- `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
2310
+ voiceFirst
2311
+ ? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
2312
+ "recorded in the character's own voice, then the clip is made to match it. " +
2313
+ "If the line has a made-up name the recorded voice can't say, the clip may be made " +
2314
+ `the usual way instead, and exodus video status ${runId} will say so.`
2315
+ : `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1731
2316
  `triggerRunId: ${triggerRunId ?? "-"}`,
1732
- "Every other scene stays as it is, and so do the pictures, the voices and the script.",
2317
+ ...(voiceFirst
2318
+ ? [
2319
+ "Every other scene stays as it is, and so do the pictures and the script.",
2320
+ "A speaking character with no voice yet gets one picked and saved for the whole run.",
2321
+ ]
2322
+ : ["Every other scene stays as it is, and so do the pictures, the voices and the script."]),
1733
2323
  ...(note
1734
2324
  ? ["Your note steers how this clip moves. It never changes the words that are spoken."]
1735
2325
  : []),
@@ -1798,6 +2388,9 @@ export async function revoiceFlow(runId, target, json, deps) {
1798
2388
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1799
2389
  if (!runRes.ok)
1800
2390
  return errorResult(runRes, json);
2391
+ if (customerAnswer(runRes.data)) {
2392
+ return adminOnlyResult("exodus video revoice is for admins.", json);
2393
+ }
1801
2394
  const run = asVideoRun(runRes.data);
1802
2395
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1803
2396
  if (!itemsRes.ok)
@@ -1964,6 +2557,10 @@ export async function voicesFlow(runId, voices, json, deps) {
1964
2557
  : await deps.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`);
1965
2558
  if (!res.ok)
1966
2559
  return errorResult(res, json);
2560
+ const customer = customerAnswer(res.data);
2561
+ if (customer) {
2562
+ return { code: 0, lines: json ? [JSON.stringify(customer)] : customerVoicesLines(customer) };
2563
+ }
1967
2564
  const sheet = res.data;
1968
2565
  if (json)
1969
2566
  return { code: 0, lines: [JSON.stringify({ ok: true, ...sheet })] };
@@ -2046,10 +2643,112 @@ export function voiceSheetLines(sheet) {
2046
2643
  : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
2047
2644
  return lines;
2048
2645
  }
2646
+ function musicLines(manifest) {
2647
+ const lines = [`music: ${manifest.musicBed}`];
2648
+ if (manifest.musicHeardScenes.length > 0) {
2649
+ lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
2650
+ }
2651
+ return lines;
2652
+ }
2653
+ export function planCustomerPull(run, items, meta) {
2654
+ const downloads = [];
2655
+ const take = (file, url) => {
2656
+ downloads.push({ file, url });
2657
+ return file;
2658
+ };
2659
+ const scenes = new Map();
2660
+ let music = null;
2661
+ for (const item of items) {
2662
+ if (item.kind === "music") {
2663
+ if (music === null && item.url)
2664
+ music = take(`music.${extFor(item.url, "audio")}`, item.url);
2665
+ continue;
2666
+ }
2667
+ if (!isPullSceneIndex(item.sceneIndex))
2668
+ continue;
2669
+ const scene = scenes.get(item.sceneIndex) ?? { index: item.sceneIndex, clip: null, voice: null };
2670
+ if (item.url && item.kind === "clip" && scene.clip === null) {
2671
+ scene.clip = take(`${scenePrefix(item.sceneIndex)}.${extFor(item.url, "video")}`, item.url);
2672
+ }
2673
+ if (item.url && item.kind === "voice" && scene.voice === null) {
2674
+ scene.voice = take(`${scenePrefix(item.sceneIndex)}.voice.${extFor(item.url, "audio")}`, item.url);
2675
+ }
2676
+ scenes.set(item.sceneIndex, scene);
2677
+ }
2678
+ const videoUrl = run.video?.url;
2679
+ const video = videoUrl ? take(`video.${extFor(videoUrl, "video")}`, videoUrl) : null;
2680
+ return {
2681
+ downloads,
2682
+ manifest: {
2683
+ runId: run.runId,
2684
+ pulledAt: meta.pulledAt,
2685
+ dashboardUrl: customerRunPage(meta.dashboardUrl, run.runId),
2686
+ scenes: [...scenes.values()].sort((a, b) => a.index - b.index),
2687
+ music,
2688
+ video,
2689
+ },
2690
+ };
2691
+ }
2692
+ async function customerPullFlow(run, dir, json, deps) {
2693
+ const runId = run.runId;
2694
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
2695
+ if (!itemsRes.ok)
2696
+ return errorResult(itemsRes, json);
2697
+ const items = customerAnswer(itemsRes.data)?.items ?? [];
2698
+ const { downloads, manifest } = planCustomerPull(run, items, {
2699
+ pulledAt: new Date(deps.now()).toISOString(),
2700
+ dashboardUrl: deps.dashboardUrl,
2701
+ });
2702
+ deps.mkdirp(dir);
2703
+ const failures = (await runPool(downloads.map((download) => () => downloadWithRetry(download, dir, deps)), PULL_CONCURRENCY)).filter((f) => f !== null);
2704
+ const landed = (file) => file !== null && !failures.some((f) => f.file === file) ? file : null;
2705
+ for (const scene of manifest.scenes) {
2706
+ scene.clip = landed(scene.clip);
2707
+ scene.voice = landed(scene.voice);
2708
+ }
2709
+ manifest.music = landed(manifest.music);
2710
+ manifest.video = landed(manifest.video);
2711
+ const manifestPath = path.join(dir, "manifest.json");
2712
+ deps.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
2713
+ const code = failures.length > 0 ? 1 : 0;
2714
+ if (json) {
2715
+ return { code, lines: [JSON.stringify({ runId, dir, wrote: downloads.length - failures.length + 1, manifest })] };
2716
+ }
2717
+ if (downloads.length === 0) {
2718
+ return {
2719
+ code: 0,
2720
+ lines: [
2721
+ "Nothing to download yet. This video hasn't made any pieces.",
2722
+ `Check where it is: exodus video status ${runId}`,
2723
+ ],
2724
+ };
2725
+ }
2726
+ return {
2727
+ code,
2728
+ lines: [
2729
+ `Pulled ${downloads.length - failures.length + 1} files into ${dir}`,
2730
+ `Every piece is listed in ${manifestPath}.`,
2731
+ ...(manifest.video ? [] : ["The finished video isn't ready yet, so only the scene pieces came down."]),
2732
+ ...(failures.length > 0
2733
+ ? [
2734
+ "",
2735
+ `${failures.length} file${failures.length === 1 ? "" : "s"} did not download:`,
2736
+ ...failures.map((f) => ` ${f.file}: ${f.error}`),
2737
+ "Run the same command again to retry.",
2738
+ ]
2739
+ : []),
2740
+ "",
2741
+ `When your cut is ready: exodus video upload ${runId} --file cut.mp4`,
2742
+ ],
2743
+ };
2744
+ }
2049
2745
  export async function pullFlow(runId, dir, json, deps) {
2050
2746
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
2051
2747
  if (!runRes.ok)
2052
2748
  return errorResult(runRes, json);
2749
+ const customer = customerAnswer(runRes.data);
2750
+ if (customer)
2751
+ return customerPullFlow(customer, dir, json, deps);
2053
2752
  const run = asVideoRun(runRes.data);
2054
2753
  const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
2055
2754
  if (!itemsRes.ok)
@@ -2089,7 +2788,23 @@ export async function pullFlow(runId, dir, json, deps) {
2089
2788
  const lines = [
2090
2789
  `Pulled ${wrote} files into ${dir}`,
2091
2790
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2791
+ ...musicLines(plan.manifest),
2092
2792
  ];
2793
+ for (const sceneIndex of plan.manifest.pendingRedo) {
2794
+ lines.push("", `Scene ${sceneIndex} is being redone right now; its file will change. Run \`exodus video wait ${runId}\`, then pull again.`);
2795
+ }
2796
+ const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
2797
+ if (voiceFirst.length > 0) {
2798
+ lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
2799
+ `scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
2800
+ }
2801
+ const redoneUsualWay = plan.manifest.scenes.filter((s) => s.voiceFirstBlockedBy?.length);
2802
+ if (redoneUsualWay.length > 0) {
2803
+ lines.push("", "Asked for voice first, but redone the usual way:");
2804
+ for (const scene of redoneUsualWay) {
2805
+ lines.push(` scene ${scene.sceneIndex}: ${cantSayNames(scene.voiceFirstBlockedBy ?? [])}`);
2806
+ }
2807
+ }
2093
2808
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
2094
2809
  if (flagged.length > 0) {
2095
2810
  lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
@@ -2237,6 +2952,46 @@ function uploadRetryResult(failed, json) {
2237
2952
  ],
2238
2953
  };
2239
2954
  }
2955
+ const CUT_SCENES_MAX = 200;
2956
+ const CUT_WORDS_MAX = 2000;
2957
+ const isTime = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
2958
+ function cutScene(row) {
2959
+ const r = row;
2960
+ return r && Number.isInteger(r.scene) && isTime(r.startSec) && isTime(r.seconds) && r.seconds > 0
2961
+ ? { scene: r.scene, startSec: r.startSec, seconds: r.seconds }
2962
+ : null;
2963
+ }
2964
+ function cutWord(row) {
2965
+ const r = row;
2966
+ return r &&
2967
+ typeof r.text === "string" &&
2968
+ r.text.trim() !== "" &&
2969
+ isTime(r.start) &&
2970
+ isTime(r.end) &&
2971
+ r.end >= r.start &&
2972
+ Number.isInteger(r.scene)
2973
+ ? { text: r.text, start: r.start, end: r.end, scene: r.scene }
2974
+ : null;
2975
+ }
2976
+ function readSidecar(file, parse, max, deps) {
2977
+ let rows;
2978
+ try {
2979
+ rows = JSON.parse(deps.readFile(file));
2980
+ }
2981
+ catch {
2982
+ return null;
2983
+ }
2984
+ if (!Array.isArray(rows) || rows.length === 0 || rows.length > max)
2985
+ return null;
2986
+ const parsed = rows.map(parse);
2987
+ return parsed.every((r) => r !== null) ? parsed : null;
2988
+ }
2989
+ export function cutSidecars(filePath, deps) {
2990
+ const base = path.join(path.dirname(filePath), path.basename(filePath, path.extname(filePath)));
2991
+ const cutScenes = readSidecar(`${base}.scenes.json`, cutScene, CUT_SCENES_MAX, deps);
2992
+ const cutWords = readSidecar(`${base}.words.json`, cutWord, CUT_WORDS_MAX, deps);
2993
+ return { ...(cutScenes ? { cutScenes } : {}), ...(cutWords ? { cutWords } : {}) };
2994
+ }
2240
2995
  export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
2241
2996
  const stat = deps.statFile(filePath);
2242
2997
  if (!stat)
@@ -2279,6 +3034,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
2279
3034
  const durationSec = asked ?? deps.probeDurationSec(filePath) ?? parseMvhdDurationSec(bytes);
2280
3035
  if (durationSec === null)
2281
3036
  return { code: 1, lines: [NO_DURATION_MESSAGE] };
3037
+ const timing = cutSidecars(filePath, deps);
2282
3038
  const minting = await withUploadRetry("mint", deps, () => deps.post(ASSET_UPLOAD_URL_PATH, {}));
2283
3039
  if (!minting.ok)
2284
3040
  return uploadRetryResult(minting, json);
@@ -2319,27 +3075,49 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
2319
3075
  runId,
2320
3076
  assetId,
2321
3077
  durationSec,
3078
+ ...timing,
2322
3079
  }));
2323
3080
  if (!attaching.ok)
2324
3081
  return uploadRetryResult(attaching, json);
2325
3082
  const attached = attaching.value;
2326
3083
  if (!attached.ok)
2327
3084
  return errorResult(attached, json);
3085
+ const customerFinal = customerAnswer(attached.data);
3086
+ if (customerFinal) {
3087
+ const { finalWatchUrl, delivered } = customerFinal;
3088
+ if (json) {
3089
+ return {
3090
+ code: 0,
3091
+ lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
3092
+ };
3093
+ }
3094
+ return {
3095
+ code: 0,
3096
+ lines: [
3097
+ `Uploaded your cut (${durationSec.toFixed(1)}s).${delivered ? " Your video is delivered." : ""}`,
3098
+ `Watch it here: ${finalWatchUrl}`,
3099
+ ],
3100
+ };
3101
+ }
2328
3102
  const final = attached.data;
2329
3103
  const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
3104
+ const delivered = final.delivered === true;
2330
3105
  if (json) {
2331
3106
  return {
2332
3107
  code: 0,
2333
- lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl })],
3108
+ lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
2334
3109
  };
2335
3110
  }
3111
+ const uploaded = `Uploaded your cut (${durationSec.toFixed(1)}s).`;
2336
3112
  return {
2337
3113
  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
- ],
3114
+ lines: delivered
3115
+ ? [`${uploaded} Your ad is delivered.`, `Watch it here: ${finalWatchUrl}`]
3116
+ : [
3117
+ uploaded,
3118
+ `Watch it here: ${finalWatchUrl}`,
3119
+ `Approve it there, or run: exodus video approve ${runId}`,
3120
+ ],
2343
3121
  };
2344
3122
  }
2345
3123
  const VALUE_FLAGS = new Set([
@@ -2349,6 +3127,10 @@ const VALUE_FLAGS = new Set([
2349
3127
  "style",
2350
3128
  "direction",
2351
3129
  "voice-path",
3130
+ "video-model",
3131
+ "voice",
3132
+ "frame",
3133
+ "pacing",
2352
3134
  "note",
2353
3135
  "out",
2354
3136
  "file",
@@ -2401,12 +3183,15 @@ export async function run(flags, occurrences) {
2401
3183
  if (sub === "shows")
2402
3184
  return printResult(await showsFlow(json, defaultDeps));
2403
3185
  if (sub === "start") {
2404
- const plan = planVideoStart(flags);
3186
+ const plan = planVideoStart(flags, occurrences);
2405
3187
  if (plan.kind === "usage")
2406
3188
  usage(plan.line);
2407
3189
  if (plan.kind === "show")
2408
3190
  return printResult(await startFlow(plan.opts, defaultDeps));
2409
- return printResult(await startScriptFlow(plan.opts, defaultDeps));
3191
+ const started = await startScriptFlow(plan.opts, defaultDeps);
3192
+ if (started.usage)
3193
+ usage(started.lines[0] ?? "");
3194
+ return printResult(started);
2410
3195
  }
2411
3196
  const runId = rest[0];
2412
3197
  const needsRunId = [
@@ -2420,6 +3205,7 @@ export async function run(flags, occurrences) {
2420
3205
  "voices",
2421
3206
  "pull",
2422
3207
  "upload",
3208
+ "director",
2423
3209
  ];
2424
3210
  if (needsRunId.includes(sub) && !runId) {
2425
3211
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
@@ -2432,6 +3218,12 @@ export async function run(flags, occurrences) {
2432
3218
  }
2433
3219
  if (sub === "storyboard")
2434
3220
  return printResult(await storyboardFlow(runId, json, defaultDeps));
3221
+ if (sub === "director") {
3222
+ const choice = parseDirectorSwitch(rest[1]);
3223
+ if (!choice)
3224
+ usage(`video director needs on, off or follow: exodus video director ${runId} on`);
3225
+ return printResult(await directorFlow(runId, choice.on, json, defaultDeps));
3226
+ }
2435
3227
  if (sub === "approve") {
2436
3228
  return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2437
3229
  }
@@ -2442,9 +3234,7 @@ export async function run(flags, occurrences) {
2442
3234
  return printResult(await flagFlow(runId, note, json, defaultDeps));
2443
3235
  }
2444
3236
  if (sub === "retry-frame") {
2445
- const nodeId = flagString(flags, "node");
2446
- if (!nodeId)
2447
- usage("video retry-frame needs --node <nodeId>.");
3237
+ const nodeId = nonBlank(flagString(flags, "node"));
2448
3238
  const sceneRaw = flagString(flags, "scene");
2449
3239
  if (sceneRaw === undefined) {
2450
3240
  usage("video retry-frame needs --scene <n>, the scene index to redo.");
@@ -2464,7 +3254,7 @@ export async function run(flags, occurrences) {
2464
3254
  if (!Number.isInteger(sceneIndex)) {
2465
3255
  usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
2466
3256
  }
2467
- return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
3257
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
2468
3258
  }
2469
3259
  if (sub === "revoice") {
2470
3260
  const sceneRaw = flagString(flags, "scene");