@aicopycoders/exodus 2026.10.1200 → 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.
@@ -32,8 +32,8 @@ workflow run`. From there the same nodes render, `video status` reads the run,
32
32
  `video pull` writes the folder, and `video upload` and `approve` finish it.
33
33
 
34
34
  A Show is no longer required. `exodus video start` starts from a script, a
35
- conceit, and a style. The same parks follow, except that the app approves the
36
- storyboard itself when its checks pass (see "What costs money"). The full line is under
35
+ conceit, and a style. The same parks follow: it stops at the storyboard for the
36
+ user's yes, unless it was started with `--one-shot` (see "What costs money"). The full line is under
37
37
  "Starting a run". An optional `--direction` note is whole-ad rules, and that
38
38
  note cannot contain a colon. "The script file" shows the right form and the
39
39
  wrong one.
@@ -101,9 +101,11 @@ never how it is made. Every answer to them says `"view": "customer"`, and the
101
101
  CLI words it the way the run page does. On their own brands they can:
102
102
 
103
103
  - `video start --script <file> --conceit <key> --style <slug>` with
104
- `--direction`, `--frame`, `--pacing`, `--music`, `--review-storyboard` and `--wait`. Any other start
104
+ `--direction`, `--frame`, `--pacing`, `--music`, `--one-shot`, `--review-storyboard`, `--stop-at-script` and `--wait`. Any other start
105
105
  flag is refused with one sentence naming it. The answer is the run id and the
106
- link to the video's page.
106
+ link to the video's page, plus what the server did to the script: laid a
107
+ plain transcript out, or wrote a script from a description (see "The script
108
+ file"). `--stop-at-script` prints that script and starts nothing.
107
109
  - `video status <runId>`: where the video is, in plain words (waiting in line,
108
110
  planning, drawing, storyboard ready for review, filming, ready with its link,
109
111
  or stopped and why). `--json` prints the server's answer as it came.
@@ -156,17 +158,24 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voic
156
158
  exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
157
159
  same, with the video model speaking voices you WROTE
158
160
  exodus video status <runId> where it is, per scene
159
- exodus video start --script <file> --conceit <kind> --style <look> [--direction "…"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--review-storyboard] [--director] [--wait]
161
+ exodus video start --script <file> --conceit <kind> --style <look> [--direction "…"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--one-shot] [--director] [--stop-at-script] [--wait]
160
162
  start from a script and the kinds the server offers.
163
+ The file can be a plain transcript, a draft or a
164
+ description: the server tidies it (see "The script
165
+ file"). --stop-at-script prints the tidied or
166
+ written script and starts nothing.
161
167
  No music bed unless you pass --music.
162
168
  --frame pov (conversation conceits) and --pacing
163
169
  (presenter conceits) change the shots; a conceit
164
170
  that can't follow one refuses it.
165
- The app approves its own storyboard when its
166
- checks pass and goes straight on to the clips,
167
- which is the part that costs. It stops at the
168
- gate only when a check finds a problem, and says
169
- why. --review-storyboard always stops there.
171
+ It stops at the storyboard for the user's yes
172
+ (Guided, the default). --one-shot makes the
173
+ whole video: the app approves its own storyboard
174
+ when its checks pass and goes straight on to the
175
+ clips, which is the part that costs. It stops
176
+ then only when a check finds a problem, and says
177
+ why. --review-storyboard is the default and
178
+ changes nothing.
170
179
  Each conceit has its own video model and voice;
171
180
  --video-model / --voice override them for one run.
172
181
  --director turns the trial director on (admins only)
@@ -261,14 +270,16 @@ a dollar on a longer ad. The expensive part comes after the storyboard: the
261
270
  voices and the clips. What happens at that point depends on how the run
262
271
  started.
263
272
 
264
- - A paste-a-script run (`exodus video start --conceit …`) checks its own
265
- storyboard once the pictures are drawn. When every check passes, the app
266
- approves the storyboard itself and goes straight on to the voices and the
267
- clips, with no stop. It stops for the user only when a check finds a problem,
268
- and the stop block says why in plain words. Started with
269
- `--review-storyboard`, it always stops at the storyboard instead. `status`
270
- prints "the app checks the storyboard and approves it itself if the checks
271
- pass" on a run headed that way.
273
+ - A paste-a-script run (`exodus video start --conceit …`) stops at the
274
+ storyboard for the user's yes by default (Guided). Started with
275
+ `--one-shot`, it checks its own storyboard once the pictures are drawn. When
276
+ every check passes, the app approves the storyboard itself and goes straight
277
+ on to the voices and the clips, with no stop. It stops for the user only when
278
+ a check finds a problem, and the stop block says why in plain words. Ask the
279
+ user before passing `--one-shot`: it spends the whole video in one go.
280
+ `status` prints "the app checks the storyboard and approves it itself if the
281
+ checks pass" on a run headed that way. `--review-storyboard` is the default
282
+ and changes nothing.
272
283
  - A Show run (`video start --show`) and a saved workflow run (`workflow run`)
273
284
  still pause at the storyboard for approval. Approving is what releases the
274
285
  voices and the clips. `status` prints "This run will pause for your approval
@@ -341,6 +352,59 @@ things worth knowing before you promise a user anything:
341
352
 
342
353
  ## The script file
343
354
 
355
+ **Pass the user's text as it is.** `exodus video start --script <file> --conceit …`
356
+ takes whatever the user gave you: a plain transcript, a rough draft, lines with
357
+ no names, voice-over labels, a title line, notes, a colon inside a spoken line,
358
+ or just a description of the ad they want. Save it to a file unchanged and start.
359
+ Do not add `CAST:`, `CTA:` or `NAME:` labels by hand, and do not rewrite it into
360
+ the format below first. The server tidies it the same way the dashboard does
361
+ when a person pastes a script, and it never changes a spoken word.
362
+
363
+ What the CLI prints, and what you tell the user:
364
+
365
+ - **Already in the format:** it starts as it is. No AI call, nothing printed
366
+ about the script.
367
+ - **Laid out:** "We laid your script out in the format the video needs.", any
368
+ speaker label it added (`SPEAKER`, `SPEAKER 1`), a short note on who each
369
+ person is, and any line it left out as not part of the ad (a title, a stage
370
+ note, a timestamp). The run has already started. Tell the user about a
371
+ left-out line in one sentence. `--json` carries the same facts under
372
+ `tidied` (`setAside`, `added.names`, `added.descriptions`).
373
+ - **Written from a description:** the file only described the ad, so the
374
+ server wrote a script for it, printed it in full, and started the run on it.
375
+ Show the user that script. `--json` carries it under `written`.
376
+ - **A question (`SCRIPT_QUESTION`, exit 1, nothing started):** the server
377
+ can't tell who says a line. The error names the line, and `--json` lists them
378
+ under `lines`. Ask the user who says it, put the name in front of that line
379
+ (`SAM: …`), and run the same command again.
380
+ - **Today's refusal (exit 1, nothing started):** the description was too thin
381
+ to write from, the user has no working AI key for the tidy, or the AI call
382
+ failed. The answer is the format's own message (for example "Line 1 has no
383
+ speaker"). Ask the user for more detail about the ad, or for their AI key in
384
+ Settings, or lay the file out in the format below yourself. A description
385
+ that reports what someone says ("a guy says it stopped his crash") is
386
+ sometimes refused this way (#3747): describe the ad without the reported
387
+ words, or give the lines as a draft, and run it again.
388
+ - **The start refused the tidied script** (for example two speakers for a
389
+ one-person kind of ad): the notes come first, then the refusal, and `--json`
390
+ carries the tidied script under `script`. Fix that script and send it. It is
391
+ already in the format, so it is not tidied or paid for again.
392
+
393
+ **`--stop-at-script`** prints the script the run would start on and starts
394
+ nothing (exit 0). Use it when the user wants to read a written or laid-out
395
+ script before anything is spent. To go ahead, save the printed script to a file
396
+ and run the same command with `--script <that file>` and without the flag.
397
+ `--json` answers `{ stoppedAtScript: true, script, tidied?, written? }`.
398
+
399
+ The tidy spends the user's own AI key, one short call. A Show run (`--show`)
400
+ is not tidied: a Show reads the script as written.
401
+
402
+ ### The exact format (optional)
403
+
404
+ A file already in this format skips the tidy: no AI call. You never need to
405
+ write it, but use it when you are writing a script from scratch for the user,
406
+ or when the tidy can't run.
407
+
344
408
  Plain text. An optional `DIRECTION:` block may open the file, before `CAST:` or
345
409
  the first spoken line. Then a `CAST:` block naming the speakers, one line per
346
410
  turn, then a `CTA:` block.
@@ -396,8 +460,9 @@ with an em dash, an en dash, or a hyphen with a space on each side. Keep that
396
460
  dash on cast lines. A reader who strips it makes the line fail.
397
461
 
398
462
  Do not use a long dash in words a character says, or in words shown on screen.
399
- Synthesised speech reads a long dash oddly, and so does burned-in text. A spoken
400
- line stays `NAME: what they say`.
463
+ Synthesised speech reads a long dash oddly, and so does burned-in text. In the
464
+ exact format a spoken line is `NAME: what they say`; a file that is not in the
465
+ format is tidied into it.
401
466
 
402
467
  ### Directing a line
403
468
 
@@ -454,8 +519,9 @@ What the reader refuses, with the code `malformed-direction`:
454
519
  An unclosed `(` is not refused. It is read as ordinary words, and the voice
455
520
  says it. Check every `(` has its `)`.
456
521
 
457
- Both ways in read the file the same way: `exodus video start --script` and
458
- `exodus workflow run --input <field>=@script.txt`. The second holds only when
522
+ Both ways in read the notes the same way: `exodus video start --script` and
523
+ `exodus workflow run --input <field>=@script.txt`. Only `video start` tidies a
524
+ file that is not in the format; a workflow's Brief must already be in it. The second holds only when
459
525
  the script goes straight from a Brief into the Storyboard. In a workflow where a
460
526
  bot writes or rewrites the script first, the notes are not read this way, and
461
527
  the bot may drop them or leave them in the spoken words.
@@ -130,20 +130,143 @@ const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{
130
130
  /**
131
131
  * #3175: one spoken word a script and a scribe spell differently ("ok", "Okay").
132
132
  * #3460: "alright" maps to the scribe's "All right" once its halves are joined.
133
+ * #3671: the #3558 words that sound the same ("your", "you're") are one spoken
134
+ * word, as in scout scribe.ts.
133
135
  */
134
136
  const SAME_WORD_SPELLINGS = new Map([
135
137
  ["ok", "okay"],
136
138
  ["alright", "allright"],
139
+ ["youre", "your"],
140
+ ["theyre", "their"],
141
+ ["there", "their"],
142
+ ["too", "to"],
137
143
  ]);
138
144
 
145
+ /**
146
+ * #3433: the number reading of scout spokenNumbers.ts, so a script's "$29" and a
147
+ * scribe's "twenty nine dollars" line up here as they do in the server's cut.
148
+ * Mirrors numericWords and its tables.
149
+ */
150
+ const ONES = [
151
+ "zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
152
+ "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
153
+ "sixteen", "seventeen", "eighteen", "nineteen",
154
+ ];
155
+ const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
156
+ const ORDINAL_ONES = [
157
+ "zeroth", "first", "second", "third", "fourth", "fifth", "sixth", "seventh",
158
+ "eighth", "ninth", "tenth", "eleventh", "twelfth", "thirteenth",
159
+ "fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth",
160
+ "nineteenth",
161
+ ];
162
+ const ORDINAL_TENS = [
163
+ "", "", "twentieth", "thirtieth", "fortieth", "fiftieth", "sixtieth",
164
+ "seventieth", "eightieth", "ninetieth",
165
+ ];
166
+ const NUMBER_WORDS = new Set([
167
+ ...ONES,
168
+ ...TENS.filter(Boolean),
169
+ "hundred",
170
+ ...ORDINAL_ONES,
171
+ ...ORDINAL_TENS.filter(Boolean),
172
+ ]);
173
+
174
+ /** Null from 1000 up: "2026" has no single spoken form, so it stays written on both sides. */
175
+ function cardinalWords(n) {
176
+ if (!Number.isInteger(n) || n < 0 || n >= 1000) return null;
177
+ if (n < 20) return [ONES[n]];
178
+ if (n < 100) {
179
+ const unit = n % 10;
180
+ const tens = TENS[Math.floor(n / 10)];
181
+ return unit === 0 ? [tens] : [tens, ONES[unit]];
182
+ }
183
+ const rest = n % 100 === 0 ? [] : (cardinalWords(n % 100) ?? []);
184
+ return [ONES[Math.floor(n / 100)], "hundred", ...rest];
185
+ }
186
+
187
+ function ordinalWords(n) {
188
+ if (!Number.isInteger(n) || n < 0 || n >= 100) return null;
189
+ if (n < 20) return [ORDINAL_ONES[n]];
190
+ const unit = n % 10;
191
+ const tens = Math.floor(n / 10);
192
+ return unit === 0 ? [ORDINAL_TENS[tens]] : [TENS[tens], ORDINAL_ONES[unit]];
193
+ }
194
+
195
+ const asInt = (digits) => Number.parseInt(digits.replace(/,/g, ""), 10);
196
+
197
+ function clockWords(hourDigits, minuteDigits) {
198
+ const hour = cardinalWords(asInt(hourDigits));
199
+ if (!hour) return null;
200
+ if (minuteDigits === undefined) return hour;
201
+ const minute = asInt(minuteDigits);
202
+ if (minute === 0) return hour;
203
+ if (minute < 10) return [...hour, "oh", ONES[minute]];
204
+ return [...hour, ...(cardinalWords(minute) ?? [])];
205
+ }
206
+
207
+ /** Spoken words for a written number form ("2:30", "2pm", "20%", "$5.50", "3rd", "1,000"), or null. */
208
+ function numericWords(core) {
209
+ const time = /^(\d{1,2}):(\d{2})$/.exec(core);
210
+ if (time) return clockWords(time[1], time[2]);
211
+
212
+ const meridiem = /^(\d{1,2})(?::(\d{2}))?([ap])\.?m\.?$/.exec(core);
213
+ if (meridiem) {
214
+ const clock = clockWords(meridiem[1], meridiem[2]);
215
+ return clock ? [...clock, `${meridiem[3]}m`] : null;
216
+ }
217
+
218
+ const percent = /^(\d[\d,]*)%$/.exec(core);
219
+ if (percent) {
220
+ const amount = cardinalWords(asInt(percent[1]));
221
+ return amount ? [...amount, "percent"] : null;
222
+ }
223
+
224
+ const money = /^\$(\d[\d,]*)(?:\.(\d{2}))?$/.exec(core);
225
+ if (money) {
226
+ const amount = asInt(money[1]);
227
+ const dollars = cardinalWords(amount);
228
+ if (!dollars) return null;
229
+ const unit = amount === 1 ? "dollar" : "dollars";
230
+ const cents = money[2] === undefined ? 0 : asInt(money[2]);
231
+ if (cents === 0) return [...dollars, unit];
232
+ return [...dollars, unit, ...(cardinalWords(cents) ?? [])];
233
+ }
234
+
235
+ const ordinal = /^(\d+)(?:st|nd|rd|th)$/.exec(core);
236
+ if (ordinal) return ordinalWords(asInt(ordinal[1]));
237
+
238
+ if (/^\d[\d,]*$/.test(core)) return cardinalWords(asInt(core));
239
+ return null;
240
+ }
241
+
242
+ /** The spoken tokens of one written word, before SAME_WORD_SPELLINGS; empty for pure punctuation. */
243
+ function spokenForms(word) {
244
+ const lower = String(word ?? "").toLowerCase();
245
+ // "twenty-five", "3-pack", "F-150" split; "e-mail" stays whole.
246
+ if (lower.includes("-")) {
247
+ const parts = lower.split("-").filter(Boolean);
248
+ const isNumber = (p) => /\d/.test(p) || NUMBER_WORDS.has(p.replace(/[^a-z]/g, ""));
249
+ if (parts.length > 1 && parts.some(isNumber)) return parts.flatMap(spokenForms);
250
+ }
251
+ // Keep a leading "$" and a trailing "%". They decide how the number is said.
252
+ const core = lower.replace(/^[^\p{L}\p{N}$]+/u, "").replace(/[^\p{L}\p{N}%]+$/u, "");
253
+ const numeric = numericWords(core);
254
+ if (numeric) return numeric;
255
+ // #3133: a scribe writes a spoken "glowmist dot com" as "Glowmist.com".
256
+ if (/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z]{2,})+$/.test(core)) {
257
+ return core.split(".").flatMap((part, i) => [...(i ? ["dot"] : []), ...spokenForms(part)]);
258
+ }
259
+ const plain = wordToken(core);
260
+ // "F150" must read the same as "F-150", which the hyphen split above expands.
261
+ if (/\p{L}/u.test(plain) && /\p{N}/u.test(plain)) {
262
+ return (plain.match(/\p{L}+|\p{N}+/gu) ?? []).flatMap(spokenForms);
263
+ }
264
+ return plain ? [plain] : [];
265
+ }
266
+
139
267
  /** The spoken tokens of one written word; empty for pure punctuation. */
140
268
  function spokenWords(word) {
141
- const core = String(word ?? "").toLowerCase().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
142
- // #3133: a scribe writes a spoken "glowmist dot com" as "Glowmist.com".
143
- const parts = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z]{2,})+$/.test(core)
144
- ? core.split(".").flatMap((part, i) => (i ? ["dot", part] : [part]))
145
- : [core];
146
- return parts.map(wordToken).filter(Boolean).map((t) => SAME_WORD_SPELLINGS.get(t) ?? t);
269
+ return spokenForms(word).map((t) => SAME_WORD_SPELLINGS.get(t) ?? t);
147
270
  }
148
271
 
149
272
  /** { tokens, sourceIndex }: tokens[i] came from written word sourceIndex[i]. */
@@ -561,6 +684,7 @@ export function scenePlans(storyboard) {
561
684
  cutawayType: typeof scene.cutawayType === "string" ? scene.cutawayType : null,
562
685
  durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
563
686
  actionTailSec: typeof scene.actionTailSec === "number" && scene.actionTailSec > 0 ? scene.actionTailSec : 0,
687
+ actionLeadSec: typeof scene.actionLeadSec === "number" && scene.actionLeadSec > 0 ? scene.actionLeadSec : 0,
564
688
  });
565
689
  }
566
690
  return plans;
@@ -646,7 +770,9 @@ export function cueEndInSegment(seg, counts, cueLineId) {
646
770
  * in order, and a cut stops short of a sound made up before or after the line
647
771
  * ("Hmm", "Um"), even at the ad's own edges (#2541). Sounds inside the line stay.
648
772
  * A scene the storyboard marks with actionTailSec keeps that long after its last
649
- * word, so the action it ends on (her setting the mat down, #3199) is seen.
773
+ * word, so the action it ends on (her setting the mat down, #3199) is seen, and
774
+ * one marked with actionLeadSec keeps that long before its first word, so the
775
+ * action it opens on (the dog stepping onto the mat, #3272) is seen.
650
776
  */
651
777
  function trimJoins(spine, reactionCues, scriptLines) {
652
778
  spine.forEach((seg, k) => {
@@ -660,7 +786,7 @@ function trimJoins(spine, reactionCues, scriptLines) {
660
786
  const after = span ? seg.words.slice(span.last + 1) : [];
661
787
  const firstWord = Math.min(...line.map((w) => w.s));
662
788
  const lastWord = Math.max(...line.map((w) => w.e));
663
- let inSec = Math.max(0, floorFrame(firstWord - lead));
789
+ let inSec = Math.max(0, floorFrame(firstWord - Math.max(lead, seg.actionLeadSec ?? 0)));
664
790
  if (before.length > 0) {
665
791
  const clear = ceilFrame(Math.max(...before.map((w) => w.e)) + EXTRA_CLEARANCE_SEC);
666
792
  inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
@@ -879,6 +1005,7 @@ export function buildTimeline({
879
1005
  sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds, inSec: 0,
880
1006
  lineIds: plan.lineIds, firstSpeaker: plan.firstSpeaker, lastSpeaker: plan.lastSpeaker,
881
1007
  baseShotId: plan.baseShotId ?? null, actionTailSec: plan.actionTailSec ?? 0,
1008
+ actionLeadSec: plan.actionLeadSec ?? 0,
882
1009
  };
883
1010
  if (voice && (narrated || !clip.hasAudio)) {
884
1011
  const voiceSeconds = probe(voice).seconds;
@@ -161,7 +161,7 @@ Accepts a workflow id **or** a name. It reports three things:
161
161
 
162
162
  Fill every required input. For a free-text brief you can compose a **bespoke brief from in-session research** — the winning angle you just worked out, the swipe you analyzed — and pass it inline (`--input brief="..."`) or, if it's long, write it to a project file and pass it with `@file` (see Chain, below). For URL/swipe sources, pass the value the source expects.
163
163
 
164
- **A brief wired straight into a Storyboard is a script, not a prompt.** On a video workflow whose Storyboard takes its `script` port from a Brief node, the text you pass for that brief boards VERBATIM, the same way a Show ad's pasted script does: one speaker per scene, every line rendered once in order, long lines split across clips, narrated lines rendered silent under the voice track. So it must be in the script form the `exodus-video` skill documents (an optional `DIRECTION:` block with no colon in its prose, then an optional `CAST:` block of `NAME — description` lines, then `NAME:` turns, then a `CTA:` block; `NARRATOR:` lines only for Narrated Story and Explainer). A brief that is not a script, or one that does not fit the wired rig's family, is refused at launch with the parser's own message and nothing is spent. Write the script to a file and pass it with `--input script=@script.txt`. A Storyboard fed by a bot is unaffected.
164
+ **A brief wired straight into a Storyboard is a script, not a prompt.** On a video workflow whose Storyboard takes its `script` port from a Brief node, the text you pass for that brief boards VERBATIM, the same way a Show ad's pasted script does: one speaker per scene, every line rendered once in order, long lines split across clips, narrated lines rendered silent under the voice track. So it must be in the script form the `exodus-video` skill documents (an optional `DIRECTION:` block with no colon in its prose, then an optional `CAST:` block of `NAME — description` lines, then `NAME:` turns, then a `CTA:` block; `NARRATOR:` lines only for Narrated Story and Explainer). A brief that is not a script, or one that does not fit the wired rig's family, is refused at launch with the parser's own message and nothing is spent. Write the script to a file and pass it with `--input script=@script.txt`. A Storyboard fed by a bot is unaffected. This path does not tidy: if the user hands you a plain transcript, a rough draft or a description, start it with `exodus video start --script <file> --conceit …` instead, which takes the text as it is and lays it out on the server (see the `exodus-video` skill's "The script file"), or run that with `--stop-at-script` first to get the script in the format for the Brief.
165
165
 
166
166
  **A saved rig can bring a rulebook with it.** Some saved rigs carry saved format rules: how many speakers the script may have, which checks run on the clips, how the cast is built. Point a Rig box at one of those rigs and the run follows its rules — an ensemble rig seats more speakers than the usual three, a talking-head rig seats one. The start receipt says which rulebook ran:
167
167
 
@@ -41,10 +41,22 @@ export interface CustomerRun {
41
41
  stopReason: CustomerStopReason | null;
42
42
  detailsPath: string | null;
43
43
  }
44
+ export interface ScriptTidied {
45
+ setAside: string[];
46
+ added: {
47
+ names: string[];
48
+ descriptions: {
49
+ name: string;
50
+ description: string;
51
+ }[];
52
+ };
53
+ }
44
54
  export interface CustomerStart {
45
55
  view: "customer";
46
56
  runId: string;
47
57
  dashboardUrl: string;
58
+ tidied?: ScriptTidied;
59
+ written?: string;
48
60
  }
49
61
  export interface CustomerStoryboard {
50
62
  view: "customer";
@@ -222,6 +222,7 @@ export interface NodeItem {
222
222
  outcome: string;
223
223
  label: string;
224
224
  reason?: string;
225
+ voiceFirstBlockedBy?: string[];
225
226
  displacedHistory?: {
226
227
  title: string;
227
228
  lines: string[];
@@ -440,6 +441,7 @@ export interface StartOptions {
440
441
  music?: boolean;
441
442
  wait: boolean;
442
443
  json: boolean;
444
+ onProgressLine?: (line: string) => void;
443
445
  }
444
446
  export interface ScriptStartOptions {
445
447
  scriptFile: string;
@@ -453,8 +455,10 @@ export interface ScriptStartOptions {
453
455
  reviewStoryboard?: true;
454
456
  director?: true;
455
457
  shots?: RunShots;
458
+ stopAtScript?: true;
456
459
  wait: boolean;
457
460
  json: boolean;
461
+ onProgressLine?: (line: string) => void;
458
462
  }
459
463
  export type VideoStartPlan = {
460
464
  kind: "usage";
@@ -482,6 +486,7 @@ export declare function waitFlow(runId: string, opts: {
482
486
  url?: string;
483
487
  intervalMs?: number;
484
488
  maxPolls?: number;
489
+ onProgressLine?: (line: string) => void;
485
490
  }, deps: VideoDeps): Promise<FlowResult>;
486
491
  export declare function statusFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
487
492
  export declare function directorLines(director: RunDirector): string[];
@@ -531,7 +536,7 @@ export interface CastVoiceRow {
531
536
  voiceId: string;
532
537
  label?: string;
533
538
  } | null;
534
- voiceFrom?: "own-pin" | "run-default";
539
+ voiceFrom?: "own-pin" | "planner-pick" | "run-default";
535
540
  availability?: {
536
541
  state: "available";
537
542
  providerName: string;
@@ -542,6 +547,18 @@ export interface CastVoiceRow {
542
547
  why: string;
543
548
  };
544
549
  description?: string;
550
+ fit?: {
551
+ note: string;
552
+ clash: boolean;
553
+ };
554
+ catalogPicks?: Array<{
555
+ voiceId: string;
556
+ name: string;
557
+ fit?: {
558
+ note: string;
559
+ clash: boolean;
560
+ };
561
+ }>;
545
562
  spokenScenes: number;
546
563
  spokenSeconds: number;
547
564
  }
@@ -576,7 +593,18 @@ export type VoiceMap = Record<string, string | {
576
593
  label?: string;
577
594
  } | null>;
578
595
  export declare function parseVoiceFlags(occurrences: FlagOccurrence[], readFile: (path: string) => string): VoiceMap | null;
596
+ export interface CatalogVoiceEntry {
597
+ voiceId: string;
598
+ name: string;
599
+ gender: string;
600
+ age: string;
601
+ accent: string;
602
+ qualities: string[];
603
+ characterVoice: boolean;
604
+ }
605
+ export declare function voiceCatalogFlow(runId: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
579
606
  export declare function voicesFlow(runId: string, voices: VoiceMap | null, json: boolean, deps: VideoDeps): Promise<FlowResult>;
607
+ export declare const VOICE_CLASH_HINT: string;
580
608
  export declare function voiceSheetLines(sheet: CastVoiceSheet): string[];
581
609
  export interface CustomerManifest {
582
610
  runId: string;
@@ -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
@@ -1276,11 +1306,17 @@ export async function startFlow(opts, deps) {
1276
1306
  lines: opts.json ? [JSON.stringify({ runId, url })] : lines,
1277
1307
  };
1278
1308
  }
1279
- const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1280
- return {
1281
- code: waited.code,
1282
- lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
1283
- };
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] };
1284
1320
  }
1285
1321
  function nonBlank(value) {
1286
1322
  if (value === undefined || value.trim().length === 0)
@@ -1362,13 +1398,32 @@ export function planVideoStart(flags, occurrences) {
1362
1398
  return { kind: "usage", line: pacingFlag.line };
1363
1399
  const videoModel = videoModelFlag.value;
1364
1400
  const voiceMode = voiceFlag.value;
1365
- const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
1366
- 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) {
1367
1404
  return {
1368
1405
  kind: "usage",
1369
1406
  line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
1370
1407
  };
1371
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
+ }
1372
1427
  const directorFlags = occurrences.filter((o) => o.flag === "director");
1373
1428
  if (directorFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1374
1429
  return {
@@ -1377,6 +1432,20 @@ export function planVideoStart(flags, occurrences) {
1377
1432
  };
1378
1433
  }
1379
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
+ }
1380
1449
  if (showId && director) {
1381
1450
  return {
1382
1451
  kind: "usage",
@@ -1445,10 +1514,12 @@ export function planVideoStart(flags, occurrences) {
1445
1514
  opts.voiceMode = voiceMode;
1446
1515
  if (musicChoice.music !== undefined)
1447
1516
  opts.music = musicChoice.music;
1448
- if (reviewStoryboard)
1517
+ if (!oneShot)
1449
1518
  opts.reviewStoryboard = true;
1450
1519
  if (director)
1451
1520
  opts.director = true;
1521
+ if (stopAtScript)
1522
+ opts.stopAtScript = true;
1452
1523
  if (frameFlag.value || pacingFlag.value) {
1453
1524
  opts.shots = {
1454
1525
  ...(frameFlag.value ? { frame: frameFlag.value } : {}),
@@ -1502,6 +1573,135 @@ function startedVideoChoice(value) {
1502
1573
  return null;
1503
1574
  return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1504
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
+ }
1505
1705
  function scriptStartedLines(runId, url, card, video, director) {
1506
1706
  return [
1507
1707
  `Started ad run ${runId}`,
@@ -1518,7 +1718,7 @@ function scriptStartedLines(runId, url, card, video, director) {
1518
1718
  `Watch it: ${url}`,
1519
1719
  ];
1520
1720
  }
1521
- function waitJsonWithCard(waited, card, video) {
1721
+ function waitJsonWith(waited, facts) {
1522
1722
  const first = waited.lines[0];
1523
1723
  if (typeof first !== "string")
1524
1724
  return waited;
@@ -1533,7 +1733,7 @@ function waitJsonWithCard(waited, card, video) {
1533
1733
  return waited;
1534
1734
  return {
1535
1735
  code: waited.code,
1536
- lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1736
+ lines: [JSON.stringify({ ...parsed, ...facts })],
1537
1737
  };
1538
1738
  }
1539
1739
  export async function startScriptFlow(opts, deps) {
@@ -1575,9 +1775,13 @@ export async function startScriptFlow(opts, deps) {
1575
1775
  ...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
1576
1776
  ...(opts.director ? { director: true } : {}),
1577
1777
  ...(opts.shots ? { shots: opts.shots } : {}),
1778
+ ...(opts.stopAtScript ? { stopAtScript: true } : {}),
1578
1779
  });
1579
1780
  if (!res.ok)
1580
- 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
+ }
1581
1785
  const customerStart = customerAnswer(res.data);
1582
1786
  if (customerStart)
1583
1787
  return customerStartedResult(customerStart, opts, deps);
@@ -1589,12 +1793,22 @@ export async function startScriptFlow(opts, deps) {
1589
1793
  const url = typeof started.url === "string" ? started.url : "";
1590
1794
  const card = isResolvedCard(started.card) ? started.card : null;
1591
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)];
1592
1805
  if (!opts.wait) {
1593
1806
  return {
1594
1807
  code: 0,
1595
1808
  lines: opts.json
1596
- ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1809
+ ? [JSON.stringify({ runId, url, ...facts })]
1597
1810
  : [
1811
+ ...head,
1598
1812
  ...scriptStartedLines(runId, url, card, video, opts.director === true),
1599
1813
  "",
1600
1814
  ...(opts.reviewStoryboard
@@ -1607,20 +1821,23 @@ export async function startScriptFlow(opts, deps) {
1607
1821
  ],
1608
1822
  };
1609
1823
  }
1610
- const waited = await waitFlow(runId, { json: opts.json, url }, deps);
1611
- if (opts.json)
1612
- return waitJsonWithCard(waited, card, video);
1613
- return {
1614
- code: waited.code,
1615
- lines: [...scriptStartedLines(runId, url, card, video, opts.director === true), "", ...waited.lines],
1616
- };
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;
1617
1826
  }
1618
1827
  async function customerStartedResult(started, opts, deps) {
1619
1828
  const { runId, dashboardUrl } = started;
1620
- 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
+ ];
1621
1837
  if (opts.wait) {
1622
- const waited = await waitFlow(runId, { json: opts.json, url: dashboardUrl }, deps);
1623
- 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;
1624
1841
  }
1625
1842
  if (opts.json)
1626
1843
  return { code: 0, lines: [JSON.stringify(started)] };
@@ -1644,6 +1861,17 @@ export async function waitFlow(runId, opts, deps) {
1644
1861
  const lines = [];
1645
1862
  let lastProgress = null;
1646
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
+ };
1647
1875
  for (let poll = 0; poll < maxPolls; poll++) {
1648
1876
  if (poll > 0)
1649
1877
  await deps.sleep(interval);
@@ -1656,12 +1884,7 @@ export async function waitFlow(runId, opts, deps) {
1656
1884
  if (customer) {
1657
1885
  const page = customerRunPage(deps.dashboardUrl, runId);
1658
1886
  if (customerStageIsWorking(customer.stage)) {
1659
- const progress = customerStageLines(customer, page)[0];
1660
- if (progress !== lastProgress) {
1661
- lastProgress = progress;
1662
- if (!opts.json)
1663
- lines.push(progress);
1664
- }
1887
+ progress(customerStageLines(customer, page)[0]);
1665
1888
  continue;
1666
1889
  }
1667
1890
  return {
@@ -1674,14 +1897,9 @@ export async function waitFlow(runId, opts, deps) {
1674
1897
  const run = asVideoRun(res.data);
1675
1898
  const stop = classifyRun(run);
1676
1899
  if (stop.at === "running") {
1677
- const progress = stop.autoRedoScenes?.length
1900
+ progress(stop.autoRedoScenes?.length
1678
1901
  ? autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)
1679
- : `Working: ${stageWord(stop.stage)}…`;
1680
- if (progress !== lastProgress) {
1681
- lastProgress = progress;
1682
- if (!opts.json)
1683
- lines.push(progress);
1684
- }
1902
+ : `Working: ${stageWord(stop.stage)}…`);
1685
1903
  continue;
1686
1904
  }
1687
1905
  let items = null;
@@ -1693,12 +1911,7 @@ export async function waitFlow(runId, opts, deps) {
1693
1911
  const redoing = items ? scenesBeingRedone(run, items) : [];
1694
1912
  redoSeen = redoing.length > 0;
1695
1913
  if (redoSeen) {
1696
- const progress = `Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`;
1697
- if (progress !== lastProgress) {
1698
- lastProgress = progress;
1699
- if (!opts.json)
1700
- lines.push(progress);
1701
- }
1914
+ progress(`Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`);
1702
1915
  continue;
1703
1916
  }
1704
1917
  }
@@ -1846,6 +2059,10 @@ export async function statusFlow(runId, json, deps) {
1846
2059
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1847
2060
  if (row.clip?.lastRedo) {
1848
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
+ }
1849
2066
  printDisplacedHistory(lines, row.clip.lastRedo);
1850
2067
  }
1851
2068
  if (row.clip?.lastRedo || row.clip?.flagged) {
@@ -1890,6 +2107,9 @@ export async function statusFlow(runId, json, deps) {
1890
2107
  lines.push("Narration master take");
1891
2108
  }
1892
2109
  }
2110
+ if (run.nodes.some((n) => n.kind === "storyboard" && n.status === "done")) {
2111
+ lines.push(...(await statusVoiceLines(runId, deps)));
2112
+ }
1893
2113
  lines.push("", hasFinal && stop.at === "finished"
1894
2114
  ? "Final cut: approved and delivered."
1895
2115
  : hasFinal
@@ -2055,7 +2275,7 @@ export async function storyboardFlow(runId, json, deps) {
2055
2275
  if (summary.length > 0) {
2056
2276
  lines.push("", ...summary);
2057
2277
  if (canChange) {
2058
- 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>`);
2059
2279
  }
2060
2280
  }
2061
2281
  }
@@ -2540,8 +2760,8 @@ export function parseVoiceFlags(occurrences, readFile) {
2540
2760
  const raw = valueFor(occurrence);
2541
2761
  const eq = raw.lastIndexOf("=");
2542
2762
  if (eq <= 0 || eq === raw.length - 1) {
2543
- throw new Error("--set must look like <character>=<voice id>, for example " +
2544
- `--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}").`);
2545
2765
  }
2546
2766
  typed[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
2547
2767
  asked = true;
@@ -2579,6 +2799,57 @@ export function parseVoiceFlags(occurrences, readFile) {
2579
2799
  }
2580
2800
  return { ...merged, ...typed };
2581
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
+ }
2582
2853
  export async function voicesFlow(runId, voices, json, deps) {
2583
2854
  const res = voices
2584
2855
  ? await deps.post(VOICES_PATH, { runId, voices })
@@ -2613,7 +2884,30 @@ function voiceWords(row) {
2613
2884
  if (!voice)
2614
2885
  return "—";
2615
2886
  const words = voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
2616
- 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] : [])];
2617
2911
  }
2618
2912
  function voiceSummaryLines(cast, notices) {
2619
2913
  const rows = cast.filter((row) => row.spokenScenes > 0 || row.voice !== null);
@@ -2621,7 +2915,7 @@ function voiceSummaryLines(cast, notices) {
2621
2915
  const lines = rows.length > 0 ? ["Voices"] : [];
2622
2916
  for (const row of rows) {
2623
2917
  lines.push(row.voice
2624
- ? ` ${row.name.padEnd(width)} → ${voiceWords(row)} ${availabilityWords(row)}`
2918
+ ? ` ${row.name.padEnd(width)} → ${voiceWords(row)} ${availabilityWords(row)}${fitWords(row)}`
2625
2919
  : ` ${row.name.padEnd(width)} → no voice chosen (keeps the generated voice)`);
2626
2920
  }
2627
2921
  for (const notice of notices)
@@ -2639,7 +2933,7 @@ export function voiceSheetLines(sheet) {
2639
2933
  const voice = voiceWords(row);
2640
2934
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
2641
2935
  `speaks in ${row.spokenScenes} scene${row.spokenScenes === 1 ? "" : "s"}, ` +
2642
- `about ${row.spokenSeconds}s`);
2936
+ `about ${row.spokenSeconds}s${fitWords(row)}`);
2643
2937
  if (row.description)
2644
2938
  lines.push(` written voice: ${row.description}`);
2645
2939
  }
@@ -3214,9 +3508,11 @@ export async function run(flags, occurrences) {
3214
3508
  const plan = planVideoStart(flags, occurrences);
3215
3509
  if (plan.kind === "usage")
3216
3510
  usage(plan.line);
3217
- if (plan.kind === "show")
3218
- return printResult(await startFlow(plan.opts, defaultDeps));
3219
- 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);
3220
3516
  if (started.usage)
3221
3517
  usage(started.lines[0] ?? "");
3222
3518
  return printResult(started);
@@ -3235,6 +3531,9 @@ export async function run(flags, occurrences) {
3235
3531
  "upload",
3236
3532
  "director",
3237
3533
  ];
3534
+ if (sub === "voices" && flags["catalog"] !== undefined && flags["catalog"] !== false) {
3535
+ return printResult(await voiceCatalogFlow(runId, json, defaultDeps));
3536
+ }
3238
3537
  if (needsRunId.includes(sub) && !runId) {
3239
3538
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
3240
3539
  }
@@ -1,6 +1,6 @@
1
1
  import { apiGet, getApiUrl } from "../lib/client.js";
2
2
  import { resolveActiveBrand } from "../lib/layout.js";
3
- import { auth401Hint } from "../lib/backend-hint.js";
3
+ import { auth401Hint, describeServer } from "../lib/backend-hint.js";
4
4
  export const helpText = `
5
5
  exodus whoami — show identity and brand context for the current API key
6
6
 
@@ -9,6 +9,7 @@ Usage:
9
9
 
10
10
  Reads EXODUS_API_KEY from your .env (or env var) and asks the dashboard which
11
11
  workspace this key is bound to. Prints:
12
+ • Server (the EXODUS_API_URL host it talks to, and whether that is live or dev)
12
13
  • Brand (workspace slug + name)
13
14
  • User email + role
14
15
  • Whether the brand has full Genesis foundation knowledge filled in
@@ -44,6 +45,7 @@ export async function run() {
44
45
  ? `\nactive: (key default)`
45
46
  : "";
46
47
  console.log(`
48
+ server: ${describeServer(getApiUrl())}
47
49
  brand: ${d.workspaceName ?? "—"} (${d.workspaceSlug ?? "—"})
48
50
  user: ${d.userEmail ?? "—"} (${d.userRole ?? "—"})
49
51
  key: ${d.keyPrefix ?? "—"}${activeLine}
@@ -1,2 +1,3 @@
1
1
  export declare function isPlaceholderApiUrl(apiUrl: string): boolean;
2
+ export declare function describeServer(apiUrl: string): string;
2
3
  export declare function auth401Hint(apiUrl: string): string;
@@ -4,13 +4,27 @@ const PLACEHOLDER_MARK = "YOUR-EXODUS-BACKEND";
4
4
  export function isPlaceholderApiUrl(apiUrl) {
5
5
  return !apiUrl || apiUrl.includes(PLACEHOLDER_MARK);
6
6
  }
7
- export function auth401Hint(apiUrl) {
8
- let host = apiUrl || "(no EXODUS_API_URL set)";
7
+ function hostLabel(apiUrl) {
9
8
  try {
10
- host = new URL(apiUrl).host;
9
+ return new URL(apiUrl).host;
11
10
  }
12
11
  catch {
12
+ return apiUrl || "(no EXODUS_API_URL set)";
13
13
  }
14
+ }
15
+ export function describeServer(apiUrl) {
16
+ const host = hostLabel(apiUrl);
17
+ if (isConvexHostOf(host, PROD_BACKEND))
18
+ return `${host} (live, xo.copycoders.ai)`;
19
+ if (isConvexHostOf(host, DEV_BACKEND))
20
+ return `${host} (dev, dev.xo.copycoders.ai)`;
21
+ return host;
22
+ }
23
+ function isConvexHostOf(host, deployment) {
24
+ return host === `${deployment}.convex.site` || host === `${deployment}.convex.cloud`;
25
+ }
26
+ export function auth401Hint(apiUrl) {
27
+ const host = hostLabel(apiUrl);
14
28
  return [
15
29
  `This API key was rejected by ${host} (HTTP 401).`,
16
30
  `Keys are environment-bound — this key may belong to the other environment.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.10.1200",
3
+ "version": "2026.10.1600-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",