@aicopycoders/exodus 2026.10.1200-beta.2 → 2026.10.1600-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.
@@ -14,6 +14,11 @@ function joinedOr(items) {
14
14
  return items[0] ?? "";
15
15
  return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
16
16
  }
17
+ function joinedAnd(items) {
18
+ if (items.length <= 1)
19
+ return items[0] ?? "";
20
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
21
+ }
17
22
  export function scriptRunPickProblem(options, conceit, style) {
18
23
  const keys = options.conceits.map((row) => row.key);
19
24
  if (!keys.includes(conceit)) {
@@ -64,6 +69,8 @@ The whole loop, in order:
64
69
  and who pays. Add --set to give someone a voice, before any clip is made.
65
70
 
66
71
  6. exodus video approve <runId> (looks right — keep going)
72
+ Guided runs only. A One-shot run approves its own storyboard and stops
73
+ here only when its checks find a problem.
67
74
  exodus video retry-frame <runId> --node <nodeId> --scene <n>
68
75
  Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
69
76
 
@@ -95,9 +102,9 @@ The whole loop, in order:
95
102
 
96
103
  10. exodus video upload <runId> --file cut.mp4
97
104
  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.
105
+ One-shot run the upload delivers the ad and you are done.
99
106
 
100
- 11. exodus video approve <runId>
107
+ 11. Guided runs only: exodus video approve <runId>
101
108
  Or click Approve on the page from step 10. If you redid a clip after
102
109
  uploading, this stops and says which scenes changed. Upload a fresh cut,
103
110
  or add --approve-stale-cut to deliver the cut you already uploaded.
@@ -109,10 +116,11 @@ Usage:
109
116
  exodus video retry-frame <runId> [--node <nodeId>] --scene <n> [--note "..."] [--json]
110
117
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
111
118
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
112
- exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
119
+ exodus video voices <runId> [--set <character>=<voice>] [--clear <character>] [--from <file.json>] [--json]
120
+ exodus video voices [<runId>] --catalog [--json]
113
121
  exodus video pull <runId> --out <dir> [--json]
114
122
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--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]
123
+ 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] [--one-shot] [--director] [--wait] [--json]
116
124
  exodus video director <runId> <on|off|follow> [--json]
117
125
 
118
126
  Options:
@@ -135,15 +143,29 @@ Options:
135
143
  --approve-stale-cut Deliver the cut you uploaded even though a clip changed
136
144
  after you uploaded it (approve). Without it, approving
137
145
  stops and names the scenes that changed
138
- --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
139
- character by its ID or by the name your script uses.
140
- Repeat it once per character
146
+ --set <who>=<voice> Give a character a voice (voices). The voice is a
147
+ catalog voice's name, like George, or an ElevenLabs
148
+ voice ID. Name the character by its ID or by the name
149
+ your script uses. Repeat it once per character
141
150
  --clear <who> Take a character's voice off again (voices). Repeatable
142
- --from <file.json> A file of characters and voice IDs (voices). --set and
143
- --clear win over the same name in the file
151
+ --from <file.json> A file of characters and voices, by name or ID (voices).
152
+ --set and --clear win over the same name in the file
153
+ --catalog List the voices you can pick by name (voices). No run
154
+ needed. With a run, also the best few for each
155
+ character who speaks
144
156
  --rejected-draft Print the storyboard draft the system turned down
145
157
  (status). It was thrown away — a record only
146
- --script <file> Text file of what the ad says (start)
158
+ --script <file> Text file of what the ad says (start). Anything works:
159
+ a plain transcript, a rough draft, or a description of
160
+ the ad. With --conceit, the server lays it out in the
161
+ format the video needs without changing a spoken word,
162
+ and says what it did. A description gets a script
163
+ written from it, printed, and the run starts on it. If
164
+ the server can't tell who says a line, it asks and
165
+ starts nothing
166
+ --stop-at-script Print the script the run would start on, and start
167
+ nothing (start, with --conceit). To read a written or
168
+ laid-out script first; send it back with --script
147
169
  --conceit <kind> Kind of ad (start)
148
170
  --style <look> Look (start)
149
171
  The server lists the kinds and looks it offers. An unknown word prints them.
@@ -163,9 +185,14 @@ Options:
163
185
  --music Put a music bed under the ad (start). An ad has no music
164
186
  unless you ask for it here
165
187
  --no-music Leave the music bed off (start), which it already is
188
+ --one-shot Make the whole video with no stop (start, with
189
+ --conceit). The app approves its own storyboard when
190
+ its checks pass and goes straight on to the clips,
191
+ which is the part that costs. Without it the run is
192
+ Guided: it stops at the storyboard for your yes
166
193
  --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
194
+ (start, with --conceit). This is the default, so the
195
+ flag changes nothing. Kept for scripts that pass it
169
196
  --director Turn the trial director on for this ad (start, with
170
197
  --conceit). Admins only. To switch it on or off later:
171
198
  exodus video director <runId> on|off|follow, where
@@ -177,7 +204,8 @@ Options:
177
204
 
178
205
  Who can use what. Admins get every command here. Beta testers with video
179
206
  switched on get these, on their own brands: start (--script, --conceit,
180
- --style, --direction, --frame, --pacing, --music, --review-storyboard),
207
+ --style, --direction, --frame, --pacing, --music, --one-shot,
208
+ --review-storyboard, --stop-at-script),
181
209
  status, storyboard, approve (the storyboard), voices, retry-frame --scene <n> (redo one scene's
182
210
  picture while the storyboard waits), pull (the finished video, each scene's
183
211
  clip and voice, and the music) and upload. These stay admin-only:
@@ -197,7 +225,9 @@ Examples:
197
225
  exodus video retry-clip run_123 --scene 3 --voice-first
198
226
  exodus video revoice run_123 --all
199
227
  exodus video voices run_123
200
- exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
228
+ exodus video voices --catalog
229
+ exodus video voices run_123 --catalog
230
+ exodus video voices run_123 --set C1=George --set "HOST 2=def456voiceid"
201
231
  exodus video voices run_123 --from voices.json
202
232
  exodus video pull run_123 --out ./ad-run_123
203
233
  exodus video upload run_123 --file ./cut.mp4
@@ -639,6 +669,7 @@ export function planFailureLines(node, runId) {
639
669
  }
640
670
  export const MUSIC_HEARD_CODE = "music-heard";
641
671
  export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
672
+ export const CAST_VIEW_LEDGER_BASE = CAST_LEDGER_BASE + 1000;
642
673
  export function musicBedState(run) {
643
674
  if (run.musicBed === undefined)
644
675
  return "unknown";
@@ -737,9 +768,25 @@ function castVoicePins(artifact) {
737
768
  }
738
769
  return pins;
739
770
  }
771
+ function lockViews(member, viewRows) {
772
+ return (member.identityRefs ?? []).flatMap((ref) => {
773
+ if (!ref.view)
774
+ return [];
775
+ const row = viewRows.find(({ artifact: art }) => art?.type !== "image"
776
+ ? false
777
+ : art.storageId
778
+ ? art.storageId === ref.storageId
779
+ : !!art.imageUrl && art.imageUrl === ref.imageUrl);
780
+ const url = (row?.artifact?.type === "image" ? row.artifact.imageUrl : undefined) ?? ref.imageUrl;
781
+ return url ? [{ view: ref.view, url }] : [];
782
+ });
783
+ }
740
784
  function planCastRefs(run, items, pins) {
741
785
  const ledger = reservedCastFrames(items);
742
- const ledgerRows = [...ledger.entries()].sort(([a], [b]) => a - b);
786
+ const isViewRow = ([sceneIndex, row]) => sceneIndex >= CAST_VIEW_LEDGER_BASE || (row.artifact?.type === "image" && row.artifact.view !== undefined);
787
+ const sorted = [...ledger.entries()].sort(([a], [b]) => a - b);
788
+ const ledgerRows = sorted.filter((r) => !isViewRow(r));
789
+ const viewRows = sorted.filter(isViewRow).map(([, row]) => row);
743
790
  const locked = run.castLock?.cast;
744
791
  const rows = [];
745
792
  if (locked !== undefined && locked.length > 0) {
@@ -765,6 +812,7 @@ function planCastRefs(run, items, pins) {
765
812
  status: item?.status ?? (url ? "done" : "missing"),
766
813
  error: item?.error ?? null,
767
814
  stem: `cast-${member.characterId}`,
815
+ views: lockViews(member, viewRows),
768
816
  });
769
817
  }
770
818
  for (const [sceneIndex, item] of ledgerRows) {
@@ -780,6 +828,7 @@ function planCastRefs(run, items, pins) {
780
828
  status: item.status,
781
829
  error: item.error ?? null,
782
830
  stem: `cast-ref-${String(sceneIndex - CAST_LEDGER_BASE).padStart(2, "0")}`,
831
+ views: [],
783
832
  });
784
833
  }
785
834
  }
@@ -794,6 +843,7 @@ function planCastRefs(run, items, pins) {
794
843
  status: item.status,
795
844
  error: item.error ?? null,
796
845
  stem: `cast-ref-${String(sceneIndex - CAST_LEDGER_BASE).padStart(2, "0")}`,
846
+ views: [],
797
847
  });
798
848
  }
799
849
  }
@@ -802,6 +852,7 @@ function planCastRefs(run, items, pins) {
802
852
  for (const row of rows) {
803
853
  const file = row.url ? `${row.stem}.${extFor(row.url, "image")}` : null;
804
854
  const pin = row.characterId ? pins.get(row.characterId) : undefined;
855
+ const views = row.views.map((v) => ({ view: v.view, file: `${row.stem}-${v.view}.${extFor(v.url, "image")}`, url: v.url }));
805
856
  cast.push({
806
857
  characterId: row.characterId,
807
858
  name: row.name,
@@ -811,9 +862,12 @@ function planCastRefs(run, items, pins) {
811
862
  voiceId: pin?.voiceId ?? null,
812
863
  voiceLabel: pin?.voiceLabel ?? null,
813
864
  voiceDescription: pin?.voiceDescription ?? null,
865
+ ...(views.length ? { views: views.map(({ view, file: viewFile }) => ({ view, file: viewFile })) } : {}),
814
866
  });
815
867
  if (file && row.url)
816
868
  downloads.push({ file, url: row.url });
869
+ for (const v of views)
870
+ downloads.push({ file: v.file, url: v.url });
817
871
  }
818
872
  return { cast, downloads };
819
873
  }
@@ -1105,6 +1159,10 @@ export function markPullFailure(manifest, failure) {
1105
1159
  for (const ref of manifest.cast) {
1106
1160
  if (ref.file === failure.file)
1107
1161
  ref.file = null;
1162
+ for (const view of ref.views ?? []) {
1163
+ if (view.file === failure.file)
1164
+ view.file = null;
1165
+ }
1108
1166
  }
1109
1167
  if (manifest.narration?.file === failure.file)
1110
1168
  manifest.narration = null;
@@ -1248,11 +1306,17 @@ export async function startFlow(opts, deps) {
1248
1306
  lines: opts.json ? [JSON.stringify({ runId, url })] : lines,
1249
1307
  };
1250
1308
  }
1251
- const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1252
- return {
1253
- code: waited.code,
1254
- lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
1255
- };
1309
+ return startedThenWait(runId, [`Started ad run ${runId}`, `Watch it: ${url}`], { ...opts, url }, deps);
1310
+ }
1311
+ async function startedThenWait(runId, head, opts, deps) {
1312
+ const stream = opts.json ? undefined : opts.onProgressLine;
1313
+ if (stream)
1314
+ for (const line of [...head, ""])
1315
+ stream(line);
1316
+ const waited = await waitFlow(runId, { json: opts.json, url: opts.url, onProgressLine: stream }, deps);
1317
+ if (opts.json || stream)
1318
+ return waited;
1319
+ return { code: waited.code, lines: [...head, "", ...waited.lines] };
1256
1320
  }
1257
1321
  function nonBlank(value) {
1258
1322
  if (value === undefined || value.trim().length === 0)
@@ -1334,13 +1398,32 @@ export function planVideoStart(flags, occurrences) {
1334
1398
  return { kind: "usage", line: pacingFlag.line };
1335
1399
  const videoModel = videoModelFlag.value;
1336
1400
  const voiceMode = voiceFlag.value;
1337
- const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
1338
- if (showId && reviewStoryboard) {
1401
+ const reviewStoryboardFlag = occurrences.some((o) => o.flag === "review-storyboard");
1402
+ const oneShot = occurrences.some((o) => o.flag === "one-shot");
1403
+ if (showId && reviewStoryboardFlag) {
1339
1404
  return {
1340
1405
  kind: "usage",
1341
1406
  line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
1342
1407
  };
1343
1408
  }
1409
+ if (showId && oneShot) {
1410
+ return {
1411
+ kind: "usage",
1412
+ line: "--one-shot works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
1413
+ };
1414
+ }
1415
+ if (oneShot && reviewStoryboardFlag) {
1416
+ return {
1417
+ kind: "usage",
1418
+ line: "Pass --one-shot or --review-storyboard, not both.",
1419
+ };
1420
+ }
1421
+ if (occurrences.some((o) => o.flag === "one-shot" && o.value !== undefined && !o.value.startsWith("--"))) {
1422
+ return {
1423
+ kind: "usage",
1424
+ line: "--one-shot takes no value. Leave it off to stop at the storyboard.",
1425
+ };
1426
+ }
1344
1427
  const directorFlags = occurrences.filter((o) => o.flag === "director");
1345
1428
  if (directorFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1346
1429
  return {
@@ -1349,6 +1432,20 @@ export function planVideoStart(flags, occurrences) {
1349
1432
  };
1350
1433
  }
1351
1434
  const director = directorFlags.length > 0;
1435
+ const stopFlags = occurrences.filter((o) => o.flag === "stop-at-script");
1436
+ if (stopFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1437
+ return {
1438
+ kind: "usage",
1439
+ line: "--stop-at-script takes no value. Leave it off to start the run.",
1440
+ };
1441
+ }
1442
+ const stopAtScript = stopFlags.length > 0;
1443
+ if (showId && stopAtScript) {
1444
+ return {
1445
+ kind: "usage",
1446
+ line: "--stop-at-script works with --conceit, not --show. A Show's script is read as you wrote it.",
1447
+ };
1448
+ }
1352
1449
  if (showId && director) {
1353
1450
  return {
1354
1451
  kind: "usage",
@@ -1417,10 +1514,12 @@ export function planVideoStart(flags, occurrences) {
1417
1514
  opts.voiceMode = voiceMode;
1418
1515
  if (musicChoice.music !== undefined)
1419
1516
  opts.music = musicChoice.music;
1420
- if (reviewStoryboard)
1517
+ if (!oneShot)
1421
1518
  opts.reviewStoryboard = true;
1422
1519
  if (director)
1423
1520
  opts.director = true;
1521
+ if (stopAtScript)
1522
+ opts.stopAtScript = true;
1424
1523
  if (frameFlag.value || pacingFlag.value) {
1425
1524
  opts.shots = {
1426
1525
  ...(frameFlag.value ? { frame: frameFlag.value } : {}),
@@ -1474,6 +1573,135 @@ function startedVideoChoice(value) {
1474
1573
  return null;
1475
1574
  return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1476
1575
  }
1576
+ function stringsOf(value) {
1577
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1578
+ }
1579
+ function isCastNote(value) {
1580
+ return isRecord(value) && typeof value.name === "string" && typeof value.description === "string";
1581
+ }
1582
+ function scriptTidiedOf(value) {
1583
+ if (!isRecord(value))
1584
+ return null;
1585
+ const added = isRecord(value.added) ? value.added : {};
1586
+ return {
1587
+ setAside: stringsOf(value.setAside),
1588
+ added: {
1589
+ names: stringsOf(added.names),
1590
+ descriptions: Array.isArray(added.descriptions) ? added.descriptions.filter(isCastNote) : [],
1591
+ },
1592
+ };
1593
+ }
1594
+ function scriptTidiedHead(tidied) {
1595
+ if (!tidied)
1596
+ return [];
1597
+ const { setAside } = tidied;
1598
+ const { names, descriptions } = tidied.added;
1599
+ return [
1600
+ "We laid your script out in the format the video needs. " +
1601
+ (setAside.length === 0
1602
+ ? "Every spoken word is unchanged."
1603
+ : "Every spoken line we kept is word for word what you wrote."),
1604
+ ...(names.length === 1 ? [`We called the person your script doesn't name ${names[0]}.`] : []),
1605
+ ...(names.length > 1 ? [`We called the people your script doesn't name ${joinedAnd(names)}.`] : []),
1606
+ ...(descriptions.length > 0
1607
+ ? [
1608
+ "We added a short note on who each person is. It's never spoken; it only guides the video:",
1609
+ ...descriptions.map(({ name, description }) => ` ${name} — ${description}`),
1610
+ ]
1611
+ : []),
1612
+ ...(setAside.length > 0
1613
+ ? [
1614
+ "We left out these lines because they don't look like part of the ad:",
1615
+ ...setAside.map((line) => ` "${line}"`),
1616
+ ]
1617
+ : []),
1618
+ "",
1619
+ ];
1620
+ }
1621
+ function scriptWrittenHead(written, started) {
1622
+ if (written === null)
1623
+ return [];
1624
+ return [
1625
+ started
1626
+ ? "Your file described the ad, so we wrote this script from it and started the run on it:"
1627
+ : "Your file described the ad, so we wrote this script from it:",
1628
+ "",
1629
+ ...written.split("\n").map((line) => (line ? ` ${line}` : "")),
1630
+ "",
1631
+ ];
1632
+ }
1633
+ function writtenOf(value) {
1634
+ return typeof value === "string" && value.trim() !== "" ? value : null;
1635
+ }
1636
+ function scriptQuestionResult(res, json) {
1637
+ const data = res.data;
1638
+ if (!isRecord(data) || !isRecord(data.error) || data.error.code !== "SCRIPT_QUESTION")
1639
+ return null;
1640
+ const message = formatApiError(res);
1641
+ const lines = stringsOf(data.lines);
1642
+ if (json) {
1643
+ return {
1644
+ code: 1,
1645
+ lines: [JSON.stringify({ ok: false, status: res.status, code: "SCRIPT_QUESTION", error: message, lines })],
1646
+ };
1647
+ }
1648
+ return { code: 1, lines: [message] };
1649
+ }
1650
+ function scriptStartRefused(res, json) {
1651
+ const data = isRecord(res.data) ? res.data : {};
1652
+ const tidied = data.tidied === undefined ? null : scriptTidiedOf(data.tidied);
1653
+ const written = writtenOf(data.written);
1654
+ if (!tidied && written === null)
1655
+ return errorResult(res, json);
1656
+ const script = typeof data.script === "string" ? data.script : null;
1657
+ const error = videoApiError(res);
1658
+ return {
1659
+ code: 1,
1660
+ lines: json
1661
+ ? [
1662
+ JSON.stringify({
1663
+ ok: false,
1664
+ status: res.status,
1665
+ error,
1666
+ ...(tidied ? { tidied } : {}),
1667
+ ...(written !== null ? { written } : {}),
1668
+ ...(script !== null ? { script } : {}),
1669
+ }),
1670
+ ]
1671
+ : [...scriptTidiedHead(tidied), ...scriptWrittenHead(written, false), error],
1672
+ };
1673
+ }
1674
+ function stoppedAtScriptResult(data, json) {
1675
+ const script = typeof data.script === "string" ? data.script : "";
1676
+ const tidied = data.tidied === undefined ? null : scriptTidiedOf(data.tidied);
1677
+ const written = writtenOf(data.written);
1678
+ if (json) {
1679
+ return {
1680
+ code: 0,
1681
+ lines: [
1682
+ JSON.stringify({
1683
+ stoppedAtScript: true,
1684
+ script,
1685
+ ...(tidied ? { tidied } : {}),
1686
+ ...(written !== null ? { written } : {}),
1687
+ }),
1688
+ ],
1689
+ };
1690
+ }
1691
+ return {
1692
+ code: 0,
1693
+ lines: [
1694
+ ...scriptTidiedHead(tidied),
1695
+ written !== null
1696
+ ? "Your file described the ad, so we wrote this script from it. Nothing has started."
1697
+ : "This is the script the run would start on. Nothing has started.",
1698
+ "",
1699
+ ...script.split("\n").map((line) => (line ? ` ${line}` : "")),
1700
+ "",
1701
+ "To start it, save it to a file and run the same command with --script <that file> and without --stop-at-script.",
1702
+ ],
1703
+ };
1704
+ }
1477
1705
  function scriptStartedLines(runId, url, card, video, director) {
1478
1706
  return [
1479
1707
  `Started ad run ${runId}`,
@@ -1490,7 +1718,7 @@ function scriptStartedLines(runId, url, card, video, director) {
1490
1718
  `Watch it: ${url}`,
1491
1719
  ];
1492
1720
  }
1493
- function waitJsonWithCard(waited, card, video) {
1721
+ function waitJsonWith(waited, facts) {
1494
1722
  const first = waited.lines[0];
1495
1723
  if (typeof first !== "string")
1496
1724
  return waited;
@@ -1505,7 +1733,7 @@ function waitJsonWithCard(waited, card, video) {
1505
1733
  return waited;
1506
1734
  return {
1507
1735
  code: waited.code,
1508
- lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1736
+ lines: [JSON.stringify({ ...parsed, ...facts })],
1509
1737
  };
1510
1738
  }
1511
1739
  export async function startScriptFlow(opts, deps) {
@@ -1547,9 +1775,13 @@ export async function startScriptFlow(opts, deps) {
1547
1775
  ...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
1548
1776
  ...(opts.director ? { director: true } : {}),
1549
1777
  ...(opts.shots ? { shots: opts.shots } : {}),
1778
+ ...(opts.stopAtScript ? { stopAtScript: true } : {}),
1550
1779
  });
1551
1780
  if (!res.ok)
1552
- return errorResult(res, opts.json);
1781
+ return scriptQuestionResult(res, opts.json) ?? scriptStartRefused(res, opts.json);
1782
+ if (isRecord(res.data) && res.data.stoppedAtScript === true) {
1783
+ return stoppedAtScriptResult(res.data, opts.json);
1784
+ }
1553
1785
  const customerStart = customerAnswer(res.data);
1554
1786
  if (customerStart)
1555
1787
  return customerStartedResult(customerStart, opts, deps);
@@ -1561,12 +1793,22 @@ export async function startScriptFlow(opts, deps) {
1561
1793
  const url = typeof started.url === "string" ? started.url : "";
1562
1794
  const card = isResolvedCard(started.card) ? started.card : null;
1563
1795
  const video = startedVideoChoice(started.videoChoice);
1796
+ const tidied = started.tidied === undefined ? null : scriptTidiedOf(started.tidied);
1797
+ const written = writtenOf(started.written);
1798
+ const facts = {
1799
+ card,
1800
+ ...(video ? { videoChoice: video } : {}),
1801
+ ...(tidied ? { tidied } : {}),
1802
+ ...(written !== null ? { written } : {}),
1803
+ };
1804
+ const head = [...scriptTidiedHead(tidied), ...scriptWrittenHead(written, true)];
1564
1805
  if (!opts.wait) {
1565
1806
  return {
1566
1807
  code: 0,
1567
1808
  lines: opts.json
1568
- ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1809
+ ? [JSON.stringify({ runId, url, ...facts })]
1569
1810
  : [
1811
+ ...head,
1570
1812
  ...scriptStartedLines(runId, url, card, video, opts.director === true),
1571
1813
  "",
1572
1814
  ...(opts.reviewStoryboard
@@ -1579,20 +1821,23 @@ export async function startScriptFlow(opts, deps) {
1579
1821
  ],
1580
1822
  };
1581
1823
  }
1582
- const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1583
- if (opts.json)
1584
- return waitJsonWithCard(waited, card, video);
1585
- return {
1586
- code: waited.code,
1587
- lines: [...scriptStartedLines(runId, url, card, video, opts.director === true), "", ...waited.lines],
1588
- };
1824
+ const waited = await startedThenWait(runId, [...head, ...scriptStartedLines(runId, url, card, video, opts.director === true)], { ...opts, url }, deps);
1825
+ return opts.json ? waitJsonWith(waited, facts) : waited;
1589
1826
  }
1590
1827
  async function customerStartedResult(started, opts, deps) {
1591
1828
  const { runId, dashboardUrl } = started;
1592
- const head = [`Started video ${runId}`, `Watch it: ${dashboardUrl}`];
1829
+ const tidied = started.tidied === undefined ? null : scriptTidiedOf(started.tidied);
1830
+ const written = writtenOf(started.written);
1831
+ const head = [
1832
+ ...scriptTidiedHead(tidied),
1833
+ ...scriptWrittenHead(written, true),
1834
+ `Started video ${runId}`,
1835
+ `Watch it: ${dashboardUrl}`,
1836
+ ];
1593
1837
  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] };
1838
+ const waited = await startedThenWait(runId, head, { ...opts, url: dashboardUrl }, deps);
1839
+ const facts = { ...(tidied ? { tidied } : {}), ...(written !== null ? { written } : {}) };
1840
+ return opts.json && (tidied || written !== null) ? waitJsonWith(waited, facts) : waited;
1596
1841
  }
1597
1842
  if (opts.json)
1598
1843
  return { code: 0, lines: [JSON.stringify(started)] };
@@ -1616,6 +1861,17 @@ export async function waitFlow(runId, opts, deps) {
1616
1861
  const lines = [];
1617
1862
  let lastProgress = null;
1618
1863
  let redoSeen = false;
1864
+ const progress = (line) => {
1865
+ if (line === lastProgress)
1866
+ return;
1867
+ lastProgress = line;
1868
+ if (opts.json)
1869
+ return;
1870
+ if (opts.onProgressLine)
1871
+ opts.onProgressLine(line);
1872
+ else
1873
+ lines.push(line);
1874
+ };
1619
1875
  for (let poll = 0; poll < maxPolls; poll++) {
1620
1876
  if (poll > 0)
1621
1877
  await deps.sleep(interval);
@@ -1628,12 +1884,7 @@ export async function waitFlow(runId, opts, deps) {
1628
1884
  if (customer) {
1629
1885
  const page = customerRunPage(deps.dashboardUrl, runId);
1630
1886
  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
- }
1887
+ progress(customerStageLines(customer, page)[0]);
1637
1888
  continue;
1638
1889
  }
1639
1890
  return {
@@ -1646,14 +1897,9 @@ export async function waitFlow(runId, opts, deps) {
1646
1897
  const run = asVideoRun(res.data);
1647
1898
  const stop = classifyRun(run);
1648
1899
  if (stop.at === "running") {
1649
- const progress = stop.autoRedoScenes?.length
1900
+ progress(stop.autoRedoScenes?.length
1650
1901
  ? autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)
1651
- : `Working: ${stageWord(stop.stage)}…`;
1652
- if (progress !== lastProgress) {
1653
- lastProgress = progress;
1654
- if (!opts.json)
1655
- lines.push(progress);
1656
- }
1902
+ : `Working: ${stageWord(stop.stage)}…`);
1657
1903
  continue;
1658
1904
  }
1659
1905
  let items = null;
@@ -1665,12 +1911,7 @@ export async function waitFlow(runId, opts, deps) {
1665
1911
  const redoing = items ? scenesBeingRedone(run, items) : [];
1666
1912
  redoSeen = redoing.length > 0;
1667
1913
  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
- }
1914
+ progress(`Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`);
1674
1915
  continue;
1675
1916
  }
1676
1917
  }
@@ -1818,6 +2059,10 @@ export async function statusFlow(runId, json, deps) {
1818
2059
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1819
2060
  if (row.clip?.lastRedo) {
1820
2061
  lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
2062
+ const blocked = row.clip.lastRedo.voiceFirstBlockedBy;
2063
+ if (blocked?.length) {
2064
+ lines.push(` clip: asked for voice first, but redone the usual way — ${cantSayNames(blocked)}`);
2065
+ }
1821
2066
  printDisplacedHistory(lines, row.clip.lastRedo);
1822
2067
  }
1823
2068
  if (row.clip?.lastRedo || row.clip?.flagged) {
@@ -1862,6 +2107,9 @@ export async function statusFlow(runId, json, deps) {
1862
2107
  lines.push("Narration master take");
1863
2108
  }
1864
2109
  }
2110
+ if (run.nodes.some((n) => n.kind === "storyboard" && n.status === "done")) {
2111
+ lines.push(...(await statusVoiceLines(runId, deps)));
2112
+ }
1865
2113
  lines.push("", hasFinal && stop.at === "finished"
1866
2114
  ? "Final cut: approved and delivered."
1867
2115
  : hasFinal
@@ -2027,7 +2275,7 @@ export async function storyboardFlow(runId, json, deps) {
2027
2275
  if (summary.length > 0) {
2028
2276
  lines.push("", ...summary);
2029
2277
  if (canChange) {
2030
- lines.push(`set voices with: exodus video voices ${runId} --set <character>=<voiceId>`);
2278
+ lines.push(`set voices with: exodus video voices ${runId} --set <character>=<voice name or ID>`);
2031
2279
  }
2032
2280
  }
2033
2281
  }
@@ -2512,8 +2760,8 @@ export function parseVoiceFlags(occurrences, readFile) {
2512
2760
  const raw = valueFor(occurrence);
2513
2761
  const eq = raw.lastIndexOf("=");
2514
2762
  if (eq <= 0 || eq === raw.length - 1) {
2515
- throw new Error("--set must look like <character>=<voice id>, for example " +
2516
- `--set C1=abc123voiceid (got "${raw}").`);
2763
+ throw new Error("--set must look like <character>=<voice>, where the voice is a catalog voice's name " +
2764
+ `or an ElevenLabs voice ID, for example --set C1=George (got "${raw}").`);
2517
2765
  }
2518
2766
  typed[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
2519
2767
  asked = true;
@@ -2551,6 +2799,57 @@ export function parseVoiceFlags(occurrences, readFile) {
2551
2799
  }
2552
2800
  return { ...merged, ...typed };
2553
2801
  }
2802
+ function catalogLines(catalog) {
2803
+ const width = (pick) => Math.max(0, ...catalog.map((voice) => pick(voice).length));
2804
+ const nameWidth = width((voice) => voice.name);
2805
+ const genderWidth = width((voice) => voice.gender);
2806
+ const ageWidth = width((voice) => voice.age);
2807
+ const accentWidth = width((voice) => voice.accent);
2808
+ return [
2809
+ "Voices you can pick by name (ElevenLabs voices every account has)",
2810
+ ...catalog.map((voice) => {
2811
+ const qualities = [...voice.qualities, ...(voice.characterVoice ? ["character voice"] : [])];
2812
+ return (` ${voice.name.padEnd(nameWidth)} ${voice.gender.padEnd(genderWidth)} ` +
2813
+ `${voice.age.padEnd(ageWidth)} ${voice.accent.padEnd(accentWidth)} ${qualities.join(", ")}`).trimEnd();
2814
+ }),
2815
+ ];
2816
+ }
2817
+ function catalogPickLines(cast) {
2818
+ const lines = [];
2819
+ for (const row of cast) {
2820
+ if (!row.catalogPicks || row.catalogPicks.length === 0)
2821
+ continue;
2822
+ const width = Math.max(...row.catalogPicks.map((pick) => pick.name.length));
2823
+ lines.push("", `Best fits for ${row.name} (${row.characterId})`);
2824
+ for (const pick of row.catalogPicks) {
2825
+ lines.push(` ${pick.name.padEnd(width)}${pick.fit ? ` ${pick.fit.note}` : ""}`.trimEnd());
2826
+ }
2827
+ }
2828
+ return lines;
2829
+ }
2830
+ export async function voiceCatalogFlow(runId, json, deps) {
2831
+ const query = runId ? `runId=${encodeURIComponent(runId)}&check=none&catalog=1` : "catalog=1";
2832
+ const res = await deps.get(`${VOICES_PATH}?${query}`);
2833
+ if (!res.ok)
2834
+ return errorResult(res, json);
2835
+ const data = (res.data ?? {});
2836
+ if (!Array.isArray(data.catalog)) {
2837
+ return { code: 1, lines: ["This server can't list the voice catalog yet. Voice IDs still work with --set."] };
2838
+ }
2839
+ if (json)
2840
+ return { code: 0, lines: [JSON.stringify({ ok: true, ...data })] };
2841
+ const cast = Array.isArray(data.cast) ? data.cast : [];
2842
+ const set = runId ? `exodus video voices ${runId}` : "exodus video voices <runId>";
2843
+ return {
2844
+ code: 0,
2845
+ lines: [
2846
+ ...catalogLines(data.catalog),
2847
+ ...catalogPickLines(cast),
2848
+ "",
2849
+ `Pick one by name: ${set} --set <character>=<Name>`,
2850
+ ],
2851
+ };
2852
+ }
2554
2853
  export async function voicesFlow(runId, voices, json, deps) {
2555
2854
  const res = voices
2556
2855
  ? await deps.post(VOICES_PATH, { runId, voices })
@@ -2585,7 +2884,30 @@ function voiceWords(row) {
2585
2884
  if (!voice)
2586
2885
  return "—";
2587
2886
  const words = voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
2588
- return row.voiceFrom === "run-default" ? `${words}, the run's default voice` : words;
2887
+ if (row.voiceFrom === "run-default")
2888
+ return `${words}, the run's default voice`;
2889
+ if (row.voiceFrom === "planner-pick")
2890
+ return `${words}, picked by the planner`;
2891
+ return words;
2892
+ }
2893
+ function fitWords(row) {
2894
+ return row.fit ? ` — ${row.fit.note}` : "";
2895
+ }
2896
+ export const VOICE_CLASH_HINT = 'To choose a voice yourself, write "voice: <Name>" on that character\'s CAST line in the script, ' +
2897
+ "or launch with --review-storyboard and change it at the storyboard stop.";
2898
+ async function statusVoiceLines(runId, deps) {
2899
+ const res = await deps
2900
+ .get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}&check=none`)
2901
+ .catch(() => null);
2902
+ if (!res?.ok)
2903
+ return [];
2904
+ const sheet = (res.data ?? {});
2905
+ const cast = Array.isArray(sheet.cast) ? sheet.cast : [];
2906
+ const summary = voiceSummaryLines(cast, []);
2907
+ if (summary.length === 0)
2908
+ return [];
2909
+ const clash = cast.some((row) => row.fit?.clash === true);
2910
+ return ["", ...summary, ...(clash ? [VOICE_CLASH_HINT] : [])];
2589
2911
  }
2590
2912
  function voiceSummaryLines(cast, notices) {
2591
2913
  const rows = cast.filter((row) => row.spokenScenes > 0 || row.voice !== null);
@@ -2593,7 +2915,7 @@ function voiceSummaryLines(cast, notices) {
2593
2915
  const lines = rows.length > 0 ? ["Voices"] : [];
2594
2916
  for (const row of rows) {
2595
2917
  lines.push(row.voice
2596
- ? ` ${row.name.padEnd(width)} → ${voiceWords(row)} ${availabilityWords(row)}`
2918
+ ? ` ${row.name.padEnd(width)} → ${voiceWords(row)} ${availabilityWords(row)}${fitWords(row)}`
2597
2919
  : ` ${row.name.padEnd(width)} → no voice chosen (keeps the generated voice)`);
2598
2920
  }
2599
2921
  for (const notice of notices)
@@ -2611,7 +2933,7 @@ export function voiceSheetLines(sheet) {
2611
2933
  const voice = voiceWords(row);
2612
2934
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
2613
2935
  `speaks in ${row.spokenScenes} scene${row.spokenScenes === 1 ? "" : "s"}, ` +
2614
- `about ${row.spokenSeconds}s`);
2936
+ `about ${row.spokenSeconds}s${fitWords(row)}`);
2615
2937
  if (row.description)
2616
2938
  lines.push(` written voice: ${row.description}`);
2617
2939
  }
@@ -3186,9 +3508,11 @@ export async function run(flags, occurrences) {
3186
3508
  const plan = planVideoStart(flags, occurrences);
3187
3509
  if (plan.kind === "usage")
3188
3510
  usage(plan.line);
3189
- if (plan.kind === "show")
3190
- return printResult(await startFlow(plan.opts, defaultDeps));
3191
- const started = await startScriptFlow(plan.opts, defaultDeps);
3511
+ const onProgressLine = (line) => console.log(line);
3512
+ if (plan.kind === "show") {
3513
+ return printResult(await startFlow({ ...plan.opts, onProgressLine }, defaultDeps));
3514
+ }
3515
+ const started = await startScriptFlow({ ...plan.opts, onProgressLine }, defaultDeps);
3192
3516
  if (started.usage)
3193
3517
  usage(started.lines[0] ?? "");
3194
3518
  return printResult(started);
@@ -3207,6 +3531,9 @@ export async function run(flags, occurrences) {
3207
3531
  "upload",
3208
3532
  "director",
3209
3533
  ];
3534
+ if (sub === "voices" && flags["catalog"] !== undefined && flags["catalog"] !== false) {
3535
+ return printResult(await voiceCatalogFlow(runId, json, defaultDeps));
3536
+ }
3210
3537
  if (needsRunId.includes(sub) && !runId) {
3211
3538
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
3212
3539
  }