@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.
- package/assets/skills/exodus-video/SKILL.md +88 -22
- package/assets/skills/exodus-video/scripts/first-cut.mjs +135 -8
- package/assets/skills/exodus-workflow/SKILL.md +1 -1
- package/dist/commands/video-customer.d.ts +12 -0
- package/dist/commands/video.d.ts +39 -2
- package/dist/commands/video.js +392 -65
- package/dist/commands/whoami.js +3 -1
- package/dist/lib/backend-hint.d.ts +1 -0
- package/dist/lib/backend-hint.js +17 -3
- package/package.json +1 -1
|
@@ -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
|
|
36
|
-
|
|
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] [--
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 …`)
|
|
265
|
-
storyboard
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
and the stop
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.
|
|
400
|
-
line
|
|
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
|
|
458
|
-
`exodus workflow run --input <field>=@script.txt`.
|
|
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
|
-
|
|
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";
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -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;
|