@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.
- package/assets/skills/exodus-video/SKILL.md +97 -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 +29 -1
- package/dist/commands/video.js +369 -64
- 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,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.
|
|
400
|
-
line
|
|
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
|
|
458
|
-
`exodus workflow run --input <field>=@script.txt`.
|
|
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
|
-
|
|
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
|
@@ -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;
|
package/dist/commands/video.js
CHANGED
|
@@ -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
|
|
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>=<
|
|
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] [--
|
|
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>=<
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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).
|
|
168
|
-
|
|
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, --
|
|
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
|
|
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
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
|
1366
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
1611
|
-
|
|
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
|
|
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
|
|
1623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>=<
|
|
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
|
|
2544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
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
|
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -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}
|
package/dist/lib/backend-hint.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
let host = apiUrl || "(no EXODUS_API_URL set)";
|
|
7
|
+
function hostLabel(apiUrl) {
|
|
9
8
|
try {
|
|
10
|
-
|
|
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.`,
|