@aicopycoders/exodus 2026.10.1200 → 2026.10.1700-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,68 @@ 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
+ - **Too thin (`SCRIPT_TOO_THIN`, exit 1, nothing started):** the file only
381
+ described the ad, and there wasn't enough in it to write a script from. The
382
+ error says what is missing, in the dashboard's words ("There isn't enough
383
+ here to write a script from. Tell us what the product is."), and `--json`
384
+ lists the gaps under `missing` (`product`, `speaker`, `what-happens`). Ask
385
+ the user for what it names, add it to the file, and run the same command
386
+ again.
387
+ - **Today's refusal (exit 1, nothing started):** the user has no working AI
388
+ key for the tidy, the AI call failed, or the server could not write a usable
389
+ script from a description. The answer is the format's own message (for
390
+ example "Line 1 has no speaker"). Ask the user for more detail about the ad,
391
+ or for their AI key in Settings, or lay the file out in the format below
392
+ yourself. A description
393
+ that reports what someone says ("a guy says it stopped his crash") is
394
+ sometimes refused this way (#3747): describe the ad without the reported
395
+ words, or give the lines as a draft, and run it again.
396
+ - **The start refused the tidied script** (for example two speakers for a
397
+ one-person kind of ad): the notes come first, then the refusal, and `--json`
398
+ carries the tidied script under `script`. Fix that script and send it. It is
399
+ already in the format, so it is not tidied or paid for again.
400
+
401
+ **`--stop-at-script`** prints the script the run would start on and starts
402
+ nothing (exit 0). A question or a description too thin to write from answers
403
+ as above instead (exit 1). Use it when the user wants to read a written or
404
+ laid-out script before anything is spent. To go ahead, save the printed script to a file
405
+ and run the same command with `--script <that file>` and without the flag.
406
+ `--json` answers `{ stoppedAtScript: true, script, tidied?, written? }`.
407
+
408
+ The tidy spends the user's own AI key, one short call. A Show run (`--show`)
409
+ is not tidied: a Show reads the script as written.
410
+
411
+ ### The exact format (optional)
412
+
413
+ A file already in this format skips the tidy: no AI call. You never need to
414
+ write it, but use it when you are writing a script from scratch for the user,
415
+ or when the tidy can't run.
416
+
344
417
  Plain text. An optional `DIRECTION:` block may open the file, before `CAST:` or
345
418
  the first spoken line. Then a `CAST:` block naming the speakers, one line per
346
419
  turn, then a `CTA:` block.
@@ -396,8 +469,9 @@ with an em dash, an en dash, or a hyphen with a space on each side. Keep that
396
469
  dash on cast lines. A reader who strips it makes the line fail.
397
470
 
398
471
  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`.
472
+ Synthesised speech reads a long dash oddly, and so does burned-in text. In the
473
+ exact format a spoken line is `NAME: what they say`; a file that is not in the
474
+ format is tidied into it.
401
475
 
402
476
  ### Directing a line
403
477
 
@@ -454,8 +528,9 @@ What the reader refuses, with the code `malformed-direction`:
454
528
  An unclosed `(` is not refused. It is read as ordinary words, and the voice
455
529
  says it. Check every `(` has its `)`.
456
530
 
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
531
+ Both ways in read the notes the same way: `exodus video start --script` and
532
+ `exodus workflow run --input <field>=@script.txt`. Only `video start` tidies a
533
+ file that is not in the format; a workflow's Brief must already be in it. The second holds only when
459
534
  the script goes straight from a Brief into the Storyboard. In a workflow where a
460
535
  bot writes or rewrites the script first, the notes are not read this way, and
461
536
  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,30 @@ 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. If a description is too thin to write
166
+ from, it says what is missing and starts nothing
167
+ --stop-at-script Print the script the run would start on, and start
168
+ nothing (start, with --conceit). To read a written or
169
+ laid-out script first; send it back with --script
147
170
  --conceit <kind> Kind of ad (start)
148
171
  --style <look> Look (start)
149
172
  The server lists the kinds and looks it offers. An unknown word prints them.
@@ -163,9 +186,14 @@ Options:
163
186
  --music Put a music bed under the ad (start). An ad has no music
164
187
  unless you ask for it here
165
188
  --no-music Leave the music bed off (start), which it already is
189
+ --one-shot Make the whole video with no stop (start, with
190
+ --conceit). The app approves its own storyboard when
191
+ its checks pass and goes straight on to the clips,
192
+ which is the part that costs. Without it the run is
193
+ Guided: it stops at the storyboard for your yes
166
194
  --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
195
+ (start, with --conceit). This is the default, so the
196
+ flag changes nothing. Kept for scripts that pass it
169
197
  --director Turn the trial director on for this ad (start, with
170
198
  --conceit). Admins only. To switch it on or off later:
171
199
  exodus video director <runId> on|off|follow, where
@@ -177,7 +205,8 @@ Options:
177
205
 
178
206
  Who can use what. Admins get every command here. Beta testers with video
179
207
  switched on get these, on their own brands: start (--script, --conceit,
180
- --style, --direction, --frame, --pacing, --music, --review-storyboard),
208
+ --style, --direction, --frame, --pacing, --music, --one-shot,
209
+ --review-storyboard, --stop-at-script),
181
210
  status, storyboard, approve (the storyboard), voices, retry-frame --scene <n> (redo one scene's
182
211
  picture while the storyboard waits), pull (the finished video, each scene's
183
212
  clip and voice, and the music) and upload. These stay admin-only:
@@ -197,7 +226,9 @@ Examples:
197
226
  exodus video retry-clip run_123 --scene 3 --voice-first
198
227
  exodus video revoice run_123 --all
199
228
  exodus video voices run_123
200
- exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
229
+ exodus video voices --catalog
230
+ exodus video voices run_123 --catalog
231
+ exodus video voices run_123 --set C1=George --set "HOST 2=def456voiceid"
201
232
  exodus video voices run_123 --from voices.json
202
233
  exodus video pull run_123 --out ./ad-run_123
203
234
  exodus video upload run_123 --file ./cut.mp4
@@ -1276,11 +1307,17 @@ export async function startFlow(opts, deps) {
1276
1307
  lines: opts.json ? [JSON.stringify({ runId, url })] : lines,
1277
1308
  };
1278
1309
  }
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
- };
1310
+ return startedThenWait(runId, [`Started ad run ${runId}`, `Watch it: ${url}`], { ...opts, url }, deps);
1311
+ }
1312
+ async function startedThenWait(runId, head, opts, deps) {
1313
+ const stream = opts.json ? undefined : opts.onProgressLine;
1314
+ if (stream)
1315
+ for (const line of [...head, ""])
1316
+ stream(line);
1317
+ const waited = await waitFlow(runId, { json: opts.json, url: opts.url, onProgressLine: stream }, deps);
1318
+ if (opts.json || stream)
1319
+ return waited;
1320
+ return { code: waited.code, lines: [...head, "", ...waited.lines] };
1284
1321
  }
1285
1322
  function nonBlank(value) {
1286
1323
  if (value === undefined || value.trim().length === 0)
@@ -1362,13 +1399,32 @@ export function planVideoStart(flags, occurrences) {
1362
1399
  return { kind: "usage", line: pacingFlag.line };
1363
1400
  const videoModel = videoModelFlag.value;
1364
1401
  const voiceMode = voiceFlag.value;
1365
- const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
1366
- if (showId && reviewStoryboard) {
1402
+ const reviewStoryboardFlag = occurrences.some((o) => o.flag === "review-storyboard");
1403
+ const oneShot = occurrences.some((o) => o.flag === "one-shot");
1404
+ if (showId && reviewStoryboardFlag) {
1367
1405
  return {
1368
1406
  kind: "usage",
1369
1407
  line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
1370
1408
  };
1371
1409
  }
1410
+ if (showId && oneShot) {
1411
+ return {
1412
+ kind: "usage",
1413
+ line: "--one-shot works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
1414
+ };
1415
+ }
1416
+ if (oneShot && reviewStoryboardFlag) {
1417
+ return {
1418
+ kind: "usage",
1419
+ line: "Pass --one-shot or --review-storyboard, not both.",
1420
+ };
1421
+ }
1422
+ if (occurrences.some((o) => o.flag === "one-shot" && o.value !== undefined && !o.value.startsWith("--"))) {
1423
+ return {
1424
+ kind: "usage",
1425
+ line: "--one-shot takes no value. Leave it off to stop at the storyboard.",
1426
+ };
1427
+ }
1372
1428
  const directorFlags = occurrences.filter((o) => o.flag === "director");
1373
1429
  if (directorFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1374
1430
  return {
@@ -1377,6 +1433,20 @@ export function planVideoStart(flags, occurrences) {
1377
1433
  };
1378
1434
  }
1379
1435
  const director = directorFlags.length > 0;
1436
+ const stopFlags = occurrences.filter((o) => o.flag === "stop-at-script");
1437
+ if (stopFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1438
+ return {
1439
+ kind: "usage",
1440
+ line: "--stop-at-script takes no value. Leave it off to start the run.",
1441
+ };
1442
+ }
1443
+ const stopAtScript = stopFlags.length > 0;
1444
+ if (showId && stopAtScript) {
1445
+ return {
1446
+ kind: "usage",
1447
+ line: "--stop-at-script works with --conceit, not --show. A Show's script is read as you wrote it.",
1448
+ };
1449
+ }
1380
1450
  if (showId && director) {
1381
1451
  return {
1382
1452
  kind: "usage",
@@ -1445,10 +1515,12 @@ export function planVideoStart(flags, occurrences) {
1445
1515
  opts.voiceMode = voiceMode;
1446
1516
  if (musicChoice.music !== undefined)
1447
1517
  opts.music = musicChoice.music;
1448
- if (reviewStoryboard)
1518
+ if (!oneShot)
1449
1519
  opts.reviewStoryboard = true;
1450
1520
  if (director)
1451
1521
  opts.director = true;
1522
+ if (stopAtScript)
1523
+ opts.stopAtScript = true;
1452
1524
  if (frameFlag.value || pacingFlag.value) {
1453
1525
  opts.shots = {
1454
1526
  ...(frameFlag.value ? { frame: frameFlag.value } : {}),
@@ -1502,6 +1574,140 @@ function startedVideoChoice(value) {
1502
1574
  return null;
1503
1575
  return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1504
1576
  }
1577
+ function stringsOf(value) {
1578
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1579
+ }
1580
+ function isCastNote(value) {
1581
+ return isRecord(value) && typeof value.name === "string" && typeof value.description === "string";
1582
+ }
1583
+ function scriptTidiedOf(value) {
1584
+ if (!isRecord(value))
1585
+ return null;
1586
+ const added = isRecord(value.added) ? value.added : {};
1587
+ return {
1588
+ setAside: stringsOf(value.setAside),
1589
+ added: {
1590
+ names: stringsOf(added.names),
1591
+ descriptions: Array.isArray(added.descriptions) ? added.descriptions.filter(isCastNote) : [],
1592
+ },
1593
+ };
1594
+ }
1595
+ function scriptTidiedHead(tidied) {
1596
+ if (!tidied)
1597
+ return [];
1598
+ const { setAside } = tidied;
1599
+ const { names, descriptions } = tidied.added;
1600
+ return [
1601
+ "We laid your script out in the format the video needs. " +
1602
+ (setAside.length === 0
1603
+ ? "Every spoken word is unchanged."
1604
+ : "Every spoken line we kept is word for word what you wrote."),
1605
+ ...(names.length === 1 ? [`We called the person your script doesn't name ${names[0]}.`] : []),
1606
+ ...(names.length > 1 ? [`We called the people your script doesn't name ${joinedAnd(names)}.`] : []),
1607
+ ...(descriptions.length > 0
1608
+ ? [
1609
+ "We added a short note on who each person is. It's never spoken; it only guides the video:",
1610
+ ...descriptions.map(({ name, description }) => ` ${name} — ${description}`),
1611
+ ]
1612
+ : []),
1613
+ ...(setAside.length > 0
1614
+ ? [
1615
+ "We left out these lines because they don't look like part of the ad:",
1616
+ ...setAside.map((line) => ` "${line}"`),
1617
+ ]
1618
+ : []),
1619
+ "",
1620
+ ];
1621
+ }
1622
+ function scriptWrittenHead(written, started) {
1623
+ if (written === null)
1624
+ return [];
1625
+ return [
1626
+ started
1627
+ ? "Your file described the ad, so we wrote this script from it and started the run on it:"
1628
+ : "Your file described the ad, so we wrote this script from it:",
1629
+ "",
1630
+ ...written.split("\n").map((line) => (line ? ` ${line}` : "")),
1631
+ "",
1632
+ ];
1633
+ }
1634
+ function writtenOf(value) {
1635
+ return typeof value === "string" && value.trim() !== "" ? value : null;
1636
+ }
1637
+ function scriptNeedsCallerResult(res, json) {
1638
+ const data = res.data;
1639
+ if (!isRecord(data) || !isRecord(data.error))
1640
+ return null;
1641
+ const code = data.error.code;
1642
+ const field = code === "SCRIPT_QUESTION" ? "lines" : code === "SCRIPT_TOO_THIN" ? "missing" : null;
1643
+ if (field === null)
1644
+ return null;
1645
+ const message = formatApiError(res);
1646
+ if (json) {
1647
+ return {
1648
+ code: 1,
1649
+ lines: [
1650
+ JSON.stringify({ ok: false, status: res.status, code, error: message, [field]: stringsOf(data[field]) }),
1651
+ ],
1652
+ };
1653
+ }
1654
+ return { code: 1, lines: [message] };
1655
+ }
1656
+ function scriptStartRefused(res, json) {
1657
+ const data = isRecord(res.data) ? res.data : {};
1658
+ const tidied = data.tidied === undefined ? null : scriptTidiedOf(data.tidied);
1659
+ const written = writtenOf(data.written);
1660
+ if (!tidied && written === null)
1661
+ return errorResult(res, json);
1662
+ const script = typeof data.script === "string" ? data.script : null;
1663
+ const error = videoApiError(res);
1664
+ return {
1665
+ code: 1,
1666
+ lines: json
1667
+ ? [
1668
+ JSON.stringify({
1669
+ ok: false,
1670
+ status: res.status,
1671
+ error,
1672
+ ...(tidied ? { tidied } : {}),
1673
+ ...(written !== null ? { written } : {}),
1674
+ ...(script !== null ? { script } : {}),
1675
+ }),
1676
+ ]
1677
+ : [...scriptTidiedHead(tidied), ...scriptWrittenHead(written, false), error],
1678
+ };
1679
+ }
1680
+ function stoppedAtScriptResult(data, json) {
1681
+ const script = typeof data.script === "string" ? data.script : "";
1682
+ const tidied = data.tidied === undefined ? null : scriptTidiedOf(data.tidied);
1683
+ const written = writtenOf(data.written);
1684
+ if (json) {
1685
+ return {
1686
+ code: 0,
1687
+ lines: [
1688
+ JSON.stringify({
1689
+ stoppedAtScript: true,
1690
+ script,
1691
+ ...(tidied ? { tidied } : {}),
1692
+ ...(written !== null ? { written } : {}),
1693
+ }),
1694
+ ],
1695
+ };
1696
+ }
1697
+ return {
1698
+ code: 0,
1699
+ lines: [
1700
+ ...scriptTidiedHead(tidied),
1701
+ written !== null
1702
+ ? "Your file described the ad, so we wrote this script from it. Nothing has started."
1703
+ : "This is the script the run would start on. Nothing has started.",
1704
+ "",
1705
+ ...script.split("\n").map((line) => (line ? ` ${line}` : "")),
1706
+ "",
1707
+ "To start it, save it to a file and run the same command with --script <that file> and without --stop-at-script.",
1708
+ ],
1709
+ };
1710
+ }
1505
1711
  function scriptStartedLines(runId, url, card, video, director) {
1506
1712
  return [
1507
1713
  `Started ad run ${runId}`,
@@ -1518,7 +1724,7 @@ function scriptStartedLines(runId, url, card, video, director) {
1518
1724
  `Watch it: ${url}`,
1519
1725
  ];
1520
1726
  }
1521
- function waitJsonWithCard(waited, card, video) {
1727
+ function waitJsonWith(waited, facts) {
1522
1728
  const first = waited.lines[0];
1523
1729
  if (typeof first !== "string")
1524
1730
  return waited;
@@ -1533,7 +1739,7 @@ function waitJsonWithCard(waited, card, video) {
1533
1739
  return waited;
1534
1740
  return {
1535
1741
  code: waited.code,
1536
- lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
1742
+ lines: [JSON.stringify({ ...parsed, ...facts })],
1537
1743
  };
1538
1744
  }
1539
1745
  export async function startScriptFlow(opts, deps) {
@@ -1575,9 +1781,13 @@ export async function startScriptFlow(opts, deps) {
1575
1781
  ...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
1576
1782
  ...(opts.director ? { director: true } : {}),
1577
1783
  ...(opts.shots ? { shots: opts.shots } : {}),
1784
+ ...(opts.stopAtScript ? { stopAtScript: true } : {}),
1578
1785
  });
1579
1786
  if (!res.ok)
1580
- return errorResult(res, opts.json);
1787
+ return scriptNeedsCallerResult(res, opts.json) ?? scriptStartRefused(res, opts.json);
1788
+ if (isRecord(res.data) && res.data.stoppedAtScript === true) {
1789
+ return stoppedAtScriptResult(res.data, opts.json);
1790
+ }
1581
1791
  const customerStart = customerAnswer(res.data);
1582
1792
  if (customerStart)
1583
1793
  return customerStartedResult(customerStart, opts, deps);
@@ -1589,12 +1799,22 @@ export async function startScriptFlow(opts, deps) {
1589
1799
  const url = typeof started.url === "string" ? started.url : "";
1590
1800
  const card = isResolvedCard(started.card) ? started.card : null;
1591
1801
  const video = startedVideoChoice(started.videoChoice);
1802
+ const tidied = started.tidied === undefined ? null : scriptTidiedOf(started.tidied);
1803
+ const written = writtenOf(started.written);
1804
+ const facts = {
1805
+ card,
1806
+ ...(video ? { videoChoice: video } : {}),
1807
+ ...(tidied ? { tidied } : {}),
1808
+ ...(written !== null ? { written } : {}),
1809
+ };
1810
+ const head = [...scriptTidiedHead(tidied), ...scriptWrittenHead(written, true)];
1592
1811
  if (!opts.wait) {
1593
1812
  return {
1594
1813
  code: 0,
1595
1814
  lines: opts.json
1596
- ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1815
+ ? [JSON.stringify({ runId, url, ...facts })]
1597
1816
  : [
1817
+ ...head,
1598
1818
  ...scriptStartedLines(runId, url, card, video, opts.director === true),
1599
1819
  "",
1600
1820
  ...(opts.reviewStoryboard
@@ -1607,20 +1827,23 @@ export async function startScriptFlow(opts, deps) {
1607
1827
  ],
1608
1828
  };
1609
1829
  }
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
- };
1830
+ const waited = await startedThenWait(runId, [...head, ...scriptStartedLines(runId, url, card, video, opts.director === true)], { ...opts, url }, deps);
1831
+ return opts.json ? waitJsonWith(waited, facts) : waited;
1617
1832
  }
1618
1833
  async function customerStartedResult(started, opts, deps) {
1619
1834
  const { runId, dashboardUrl } = started;
1620
- const head = [`Started video ${runId}`, `Watch it: ${dashboardUrl}`];
1835
+ const tidied = started.tidied === undefined ? null : scriptTidiedOf(started.tidied);
1836
+ const written = writtenOf(started.written);
1837
+ const head = [
1838
+ ...scriptTidiedHead(tidied),
1839
+ ...scriptWrittenHead(written, true),
1840
+ `Started video ${runId}`,
1841
+ `Watch it: ${dashboardUrl}`,
1842
+ ];
1621
1843
  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] };
1844
+ const waited = await startedThenWait(runId, head, { ...opts, url: dashboardUrl }, deps);
1845
+ const facts = { ...(tidied ? { tidied } : {}), ...(written !== null ? { written } : {}) };
1846
+ return opts.json && (tidied || written !== null) ? waitJsonWith(waited, facts) : waited;
1624
1847
  }
1625
1848
  if (opts.json)
1626
1849
  return { code: 0, lines: [JSON.stringify(started)] };
@@ -1644,6 +1867,17 @@ export async function waitFlow(runId, opts, deps) {
1644
1867
  const lines = [];
1645
1868
  let lastProgress = null;
1646
1869
  let redoSeen = false;
1870
+ const progress = (line) => {
1871
+ if (line === lastProgress)
1872
+ return;
1873
+ lastProgress = line;
1874
+ if (opts.json)
1875
+ return;
1876
+ if (opts.onProgressLine)
1877
+ opts.onProgressLine(line);
1878
+ else
1879
+ lines.push(line);
1880
+ };
1647
1881
  for (let poll = 0; poll < maxPolls; poll++) {
1648
1882
  if (poll > 0)
1649
1883
  await deps.sleep(interval);
@@ -1656,12 +1890,7 @@ export async function waitFlow(runId, opts, deps) {
1656
1890
  if (customer) {
1657
1891
  const page = customerRunPage(deps.dashboardUrl, runId);
1658
1892
  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
- }
1893
+ progress(customerStageLines(customer, page)[0]);
1665
1894
  continue;
1666
1895
  }
1667
1896
  return {
@@ -1674,14 +1903,9 @@ export async function waitFlow(runId, opts, deps) {
1674
1903
  const run = asVideoRun(res.data);
1675
1904
  const stop = classifyRun(run);
1676
1905
  if (stop.at === "running") {
1677
- const progress = stop.autoRedoScenes?.length
1906
+ progress(stop.autoRedoScenes?.length
1678
1907
  ? 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
- }
1908
+ : `Working: ${stageWord(stop.stage)}…`);
1685
1909
  continue;
1686
1910
  }
1687
1911
  let items = null;
@@ -1693,12 +1917,7 @@ export async function waitFlow(runId, opts, deps) {
1693
1917
  const redoing = items ? scenesBeingRedone(run, items) : [];
1694
1918
  redoSeen = redoing.length > 0;
1695
1919
  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
- }
1920
+ progress(`Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`);
1702
1921
  continue;
1703
1922
  }
1704
1923
  }
@@ -1846,6 +2065,10 @@ export async function statusFlow(runId, json, deps) {
1846
2065
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
1847
2066
  if (row.clip?.lastRedo) {
1848
2067
  lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
2068
+ const blocked = row.clip.lastRedo.voiceFirstBlockedBy;
2069
+ if (blocked?.length) {
2070
+ lines.push(` clip: asked for voice first, but redone the usual way — ${cantSayNames(blocked)}`);
2071
+ }
1849
2072
  printDisplacedHistory(lines, row.clip.lastRedo);
1850
2073
  }
1851
2074
  if (row.clip?.lastRedo || row.clip?.flagged) {
@@ -1890,6 +2113,9 @@ export async function statusFlow(runId, json, deps) {
1890
2113
  lines.push("Narration master take");
1891
2114
  }
1892
2115
  }
2116
+ if (run.nodes.some((n) => n.kind === "storyboard" && n.status === "done")) {
2117
+ lines.push(...(await statusVoiceLines(runId, deps)));
2118
+ }
1893
2119
  lines.push("", hasFinal && stop.at === "finished"
1894
2120
  ? "Final cut: approved and delivered."
1895
2121
  : hasFinal
@@ -2055,7 +2281,7 @@ export async function storyboardFlow(runId, json, deps) {
2055
2281
  if (summary.length > 0) {
2056
2282
  lines.push("", ...summary);
2057
2283
  if (canChange) {
2058
- lines.push(`set voices with: exodus video voices ${runId} --set <character>=<voiceId>`);
2284
+ lines.push(`set voices with: exodus video voices ${runId} --set <character>=<voice name or ID>`);
2059
2285
  }
2060
2286
  }
2061
2287
  }
@@ -2540,8 +2766,8 @@ export function parseVoiceFlags(occurrences, readFile) {
2540
2766
  const raw = valueFor(occurrence);
2541
2767
  const eq = raw.lastIndexOf("=");
2542
2768
  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}").`);
2769
+ throw new Error("--set must look like <character>=<voice>, where the voice is a catalog voice's name " +
2770
+ `or an ElevenLabs voice ID, for example --set C1=George (got "${raw}").`);
2545
2771
  }
2546
2772
  typed[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
2547
2773
  asked = true;
@@ -2579,6 +2805,57 @@ export function parseVoiceFlags(occurrences, readFile) {
2579
2805
  }
2580
2806
  return { ...merged, ...typed };
2581
2807
  }
2808
+ function catalogLines(catalog) {
2809
+ const width = (pick) => Math.max(0, ...catalog.map((voice) => pick(voice).length));
2810
+ const nameWidth = width((voice) => voice.name);
2811
+ const genderWidth = width((voice) => voice.gender);
2812
+ const ageWidth = width((voice) => voice.age);
2813
+ const accentWidth = width((voice) => voice.accent);
2814
+ return [
2815
+ "Voices you can pick by name (ElevenLabs voices every account has)",
2816
+ ...catalog.map((voice) => {
2817
+ const qualities = [...voice.qualities, ...(voice.characterVoice ? ["character voice"] : [])];
2818
+ return (` ${voice.name.padEnd(nameWidth)} ${voice.gender.padEnd(genderWidth)} ` +
2819
+ `${voice.age.padEnd(ageWidth)} ${voice.accent.padEnd(accentWidth)} ${qualities.join(", ")}`).trimEnd();
2820
+ }),
2821
+ ];
2822
+ }
2823
+ function catalogPickLines(cast) {
2824
+ const lines = [];
2825
+ for (const row of cast) {
2826
+ if (!row.catalogPicks || row.catalogPicks.length === 0)
2827
+ continue;
2828
+ const width = Math.max(...row.catalogPicks.map((pick) => pick.name.length));
2829
+ lines.push("", `Best fits for ${row.name} (${row.characterId})`);
2830
+ for (const pick of row.catalogPicks) {
2831
+ lines.push(` ${pick.name.padEnd(width)}${pick.fit ? ` ${pick.fit.note}` : ""}`.trimEnd());
2832
+ }
2833
+ }
2834
+ return lines;
2835
+ }
2836
+ export async function voiceCatalogFlow(runId, json, deps) {
2837
+ const query = runId ? `runId=${encodeURIComponent(runId)}&check=none&catalog=1` : "catalog=1";
2838
+ const res = await deps.get(`${VOICES_PATH}?${query}`);
2839
+ if (!res.ok)
2840
+ return errorResult(res, json);
2841
+ const data = (res.data ?? {});
2842
+ if (!Array.isArray(data.catalog)) {
2843
+ return { code: 1, lines: ["This server can't list the voice catalog yet. Voice IDs still work with --set."] };
2844
+ }
2845
+ if (json)
2846
+ return { code: 0, lines: [JSON.stringify({ ok: true, ...data })] };
2847
+ const cast = Array.isArray(data.cast) ? data.cast : [];
2848
+ const set = runId ? `exodus video voices ${runId}` : "exodus video voices <runId>";
2849
+ return {
2850
+ code: 0,
2851
+ lines: [
2852
+ ...catalogLines(data.catalog),
2853
+ ...catalogPickLines(cast),
2854
+ "",
2855
+ `Pick one by name: ${set} --set <character>=<Name>`,
2856
+ ],
2857
+ };
2858
+ }
2582
2859
  export async function voicesFlow(runId, voices, json, deps) {
2583
2860
  const res = voices
2584
2861
  ? await deps.post(VOICES_PATH, { runId, voices })
@@ -2613,7 +2890,30 @@ function voiceWords(row) {
2613
2890
  if (!voice)
2614
2891
  return "—";
2615
2892
  const words = voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
2616
- return row.voiceFrom === "run-default" ? `${words}, the run's default voice` : words;
2893
+ if (row.voiceFrom === "run-default")
2894
+ return `${words}, the run's default voice`;
2895
+ if (row.voiceFrom === "planner-pick")
2896
+ return `${words}, picked by the planner`;
2897
+ return words;
2898
+ }
2899
+ function fitWords(row) {
2900
+ return row.fit ? ` — ${row.fit.note}` : "";
2901
+ }
2902
+ export const VOICE_CLASH_HINT = 'To choose a voice yourself, write "voice: <Name>" on that character\'s CAST line in the script, ' +
2903
+ "or launch with --review-storyboard and change it at the storyboard stop.";
2904
+ async function statusVoiceLines(runId, deps) {
2905
+ const res = await deps
2906
+ .get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}&check=none`)
2907
+ .catch(() => null);
2908
+ if (!res?.ok)
2909
+ return [];
2910
+ const sheet = (res.data ?? {});
2911
+ const cast = Array.isArray(sheet.cast) ? sheet.cast : [];
2912
+ const summary = voiceSummaryLines(cast, []);
2913
+ if (summary.length === 0)
2914
+ return [];
2915
+ const clash = cast.some((row) => row.fit?.clash === true);
2916
+ return ["", ...summary, ...(clash ? [VOICE_CLASH_HINT] : [])];
2617
2917
  }
2618
2918
  function voiceSummaryLines(cast, notices) {
2619
2919
  const rows = cast.filter((row) => row.spokenScenes > 0 || row.voice !== null);
@@ -2621,7 +2921,7 @@ function voiceSummaryLines(cast, notices) {
2621
2921
  const lines = rows.length > 0 ? ["Voices"] : [];
2622
2922
  for (const row of rows) {
2623
2923
  lines.push(row.voice
2624
- ? ` ${row.name.padEnd(width)} → ${voiceWords(row)} ${availabilityWords(row)}`
2924
+ ? ` ${row.name.padEnd(width)} → ${voiceWords(row)} ${availabilityWords(row)}${fitWords(row)}`
2625
2925
  : ` ${row.name.padEnd(width)} → no voice chosen (keeps the generated voice)`);
2626
2926
  }
2627
2927
  for (const notice of notices)
@@ -2639,7 +2939,7 @@ export function voiceSheetLines(sheet) {
2639
2939
  const voice = voiceWords(row);
2640
2940
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
2641
2941
  `speaks in ${row.spokenScenes} scene${row.spokenScenes === 1 ? "" : "s"}, ` +
2642
- `about ${row.spokenSeconds}s`);
2942
+ `about ${row.spokenSeconds}s${fitWords(row)}`);
2643
2943
  if (row.description)
2644
2944
  lines.push(` written voice: ${row.description}`);
2645
2945
  }
@@ -3214,9 +3514,11 @@ export async function run(flags, occurrences) {
3214
3514
  const plan = planVideoStart(flags, occurrences);
3215
3515
  if (plan.kind === "usage")
3216
3516
  usage(plan.line);
3217
- if (plan.kind === "show")
3218
- return printResult(await startFlow(plan.opts, defaultDeps));
3219
- const started = await startScriptFlow(plan.opts, defaultDeps);
3517
+ const onProgressLine = (line) => console.log(line);
3518
+ if (plan.kind === "show") {
3519
+ return printResult(await startFlow({ ...plan.opts, onProgressLine }, defaultDeps));
3520
+ }
3521
+ const started = await startScriptFlow({ ...plan.opts, onProgressLine }, defaultDeps);
3220
3522
  if (started.usage)
3221
3523
  usage(started.lines[0] ?? "");
3222
3524
  return printResult(started);
@@ -3235,6 +3537,9 @@ export async function run(flags, occurrences) {
3235
3537
  "upload",
3236
3538
  "director",
3237
3539
  ];
3540
+ if (sub === "voices" && flags["catalog"] !== undefined && flags["catalog"] !== false) {
3541
+ return printResult(await voiceCatalogFlow(runId, json, defaultDeps));
3542
+ }
3238
3543
  if (needsRunId.includes(sub) && !runId) {
3239
3544
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
3240
3545
  }
@@ -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.1700-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",