@aicopycoders/exodus 2026.10.1200-beta.2 → 2026.10.1600-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -79,9 +79,11 @@ export type ArtifactSubset = {
79
79
  type: "image";
80
80
  imageUrl?: string;
81
81
  storageId?: string;
82
- anchor?: "set-plate" | "product-sheet" | "filled-plate";
82
+ anchor?: "set-plate" | "product-sheet" | "filled-plate" | "product-plate" | "layout-plate";
83
83
  holds?: string[];
84
84
  lid?: "closed";
85
+ camera?: string;
86
+ view?: "side" | "back" | "three-quarter";
85
87
  } | {
86
88
  type: "video";
87
89
  sceneIndex?: number;
@@ -158,6 +160,7 @@ export interface PullCastLockMember {
158
160
  identityRefs?: Array<{
159
161
  storageId?: string;
160
162
  imageUrl?: string;
163
+ view?: string;
161
164
  }>;
162
165
  }
163
166
  export interface PullCastLock {
@@ -219,6 +222,7 @@ export interface NodeItem {
219
222
  outcome: string;
220
223
  label: string;
221
224
  reason?: string;
225
+ voiceFirstBlockedBy?: string[];
222
226
  displacedHistory?: {
223
227
  title: string;
224
228
  lines: string[];
@@ -363,6 +367,11 @@ export interface ManifestCastRef {
363
367
  voiceId: string | null;
364
368
  voiceLabel: string | null;
365
369
  voiceDescription: string | null;
370
+ views?: ManifestCastView[];
371
+ }
372
+ export interface ManifestCastView {
373
+ view: string;
374
+ file: string | null;
366
375
  }
367
376
  export type MusicBedState = "on" | "off" | "unknown";
368
377
  export interface RunVideoChoice {
@@ -409,6 +418,7 @@ export interface RunShots {
409
418
  pacing?: string;
410
419
  }
411
420
  export declare const CAST_LEDGER_BASE: number;
421
+ export declare const CAST_VIEW_LEDGER_BASE: number;
412
422
  export declare function musicBedState(run: VideoRun): MusicBedState;
413
423
  export interface PullPlan {
414
424
  downloads: PullDownload[];
@@ -431,6 +441,7 @@ export interface StartOptions {
431
441
  music?: boolean;
432
442
  wait: boolean;
433
443
  json: boolean;
444
+ onProgressLine?: (line: string) => void;
434
445
  }
435
446
  export interface ScriptStartOptions {
436
447
  scriptFile: string;
@@ -444,8 +455,10 @@ export interface ScriptStartOptions {
444
455
  reviewStoryboard?: true;
445
456
  director?: true;
446
457
  shots?: RunShots;
458
+ stopAtScript?: true;
447
459
  wait: boolean;
448
460
  json: boolean;
461
+ onProgressLine?: (line: string) => void;
449
462
  }
450
463
  export type VideoStartPlan = {
451
464
  kind: "usage";
@@ -473,6 +486,7 @@ export declare function waitFlow(runId: string, opts: {
473
486
  url?: string;
474
487
  intervalMs?: number;
475
488
  maxPolls?: number;
489
+ onProgressLine?: (line: string) => void;
476
490
  }, deps: VideoDeps): Promise<FlowResult>;
477
491
  export declare function statusFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
478
492
  export declare function directorLines(director: RunDirector): string[];
@@ -522,7 +536,7 @@ export interface CastVoiceRow {
522
536
  voiceId: string;
523
537
  label?: string;
524
538
  } | null;
525
- voiceFrom?: "own-pin" | "run-default";
539
+ voiceFrom?: "own-pin" | "planner-pick" | "run-default";
526
540
  availability?: {
527
541
  state: "available";
528
542
  providerName: string;
@@ -533,6 +547,18 @@ export interface CastVoiceRow {
533
547
  why: string;
534
548
  };
535
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
+ }>;
536
562
  spokenScenes: number;
537
563
  spokenSeconds: number;
538
564
  }
@@ -567,7 +593,18 @@ export type VoiceMap = Record<string, string | {
567
593
  label?: string;
568
594
  } | null>;
569
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>;
570
606
  export declare function voicesFlow(runId: string, voices: VoiceMap | null, json: boolean, deps: VideoDeps): Promise<FlowResult>;
607
+ export declare const VOICE_CLASH_HINT: string;
571
608
  export declare function voiceSheetLines(sheet: CastVoiceSheet): string[];
572
609
  export interface CustomerManifest {
573
610
  runId: string;