@aicopycoders/exodus 2026.9.2000-beta.20 → 2026.9.2000-beta.22
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 +55 -18
- package/assets/skills/exodus-video/scripts/first-cut.mjs +173 -23
- package/dist/commands/video.d.ts +37 -0
- package/dist/commands/video.js +216 -16
- package/dist/commands/workflow.d.ts +1 -0
- package/dist/commands/workflow.js +16 -2
- package/package.json +1 -1
|
@@ -32,7 +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
|
|
35
|
+
conceit, and a style. The same parks follow, except that the app approves the
|
|
36
|
+
storyboard itself when its checks pass (see "What costs money"). The full line is under
|
|
36
37
|
"Starting a run". An optional `--direction` note is whole-ad rules, and that
|
|
37
38
|
note cannot contain a colon. "The script file" shows the right form and the
|
|
38
39
|
wrong one.
|
|
@@ -84,11 +85,20 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voic
|
|
|
84
85
|
exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
|
|
85
86
|
same, with the video model speaking voices you WROTE
|
|
86
87
|
exodus video status <runId> where it is, per scene
|
|
87
|
-
exodus video start --script <file> --conceit <podcast|ugc|stage|street|personification> --style <slug> [--direction "…"] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--wait]
|
|
88
|
+
exodus video start --script <file> --conceit <podcast|ugc|stage|street|personification> --style <slug> [--direction "…"] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--review-storyboard] [--director] [--wait]
|
|
88
89
|
start from a script, a conceit and a look.
|
|
89
90
|
No music bed unless you pass --music.
|
|
91
|
+
The app approves its own storyboard when its
|
|
92
|
+
checks pass and goes straight on to the clips,
|
|
93
|
+
which is the part that costs. It stops at the
|
|
94
|
+
gate only when a check finds a problem, and says
|
|
95
|
+
why. --review-storyboard always stops there.
|
|
90
96
|
Each conceit has its own video model and voice;
|
|
91
|
-
--video-model / --voice override them for one run
|
|
97
|
+
--video-model / --voice override them for one run.
|
|
98
|
+
--director turns the trial director on (admins only)
|
|
99
|
+
exodus video director <runId> on|off|follow the trial director for one run (admins only);
|
|
100
|
+
follow = the brand's own setting. status shows
|
|
101
|
+
what it is doing and its last three notes
|
|
92
102
|
```
|
|
93
103
|
|
|
94
104
|
**From the storyboard on.**
|
|
@@ -169,18 +179,31 @@ minutes with "Still running after 60 minutes" and exit 0; that is not done, so
|
|
|
169
179
|
check `status` before treating it as finished. Do not poll `status` in a tight
|
|
170
180
|
loop; a full run is several minutes.
|
|
171
181
|
|
|
172
|
-
**What costs money.**
|
|
173
|
-
reference, cast and scene pictures
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
**What costs money.** Every run writes the storyboard first and then draws the
|
|
183
|
+
reference, cast and scene pictures. Each picture is a few cents and each scene
|
|
184
|
+
draws a few to choose from: about 25 cents on a small test, and it can approach
|
|
185
|
+
a dollar on a longer ad. The expensive part comes after the storyboard: the
|
|
186
|
+
voices and the clips. What happens at that point depends on how the run
|
|
187
|
+
started.
|
|
188
|
+
|
|
189
|
+
- A paste-a-script run (`exodus video start --conceit …`) checks its own
|
|
190
|
+
storyboard once the pictures are drawn. When every check passes, the app
|
|
191
|
+
approves the storyboard itself and goes straight on to the voices and the
|
|
192
|
+
clips, with no stop. It stops for the user only when a check finds a problem,
|
|
193
|
+
and the stop block says why in plain words. Started with
|
|
194
|
+
`--review-storyboard`, it always stops at the storyboard instead. `status`
|
|
195
|
+
prints "the app checks the storyboard and approves it itself if the checks
|
|
196
|
+
pass" on a run headed that way.
|
|
197
|
+
- A Show run (`video start --show`) and a saved workflow run (`workflow run`)
|
|
198
|
+
still pause at the storyboard for approval. Approving is what releases the
|
|
199
|
+
voices and the clips. `status` prints "This run will pause for your approval
|
|
200
|
+
once the frames are ready" on a run headed for that pause.
|
|
201
|
+
|
|
202
|
+
Either way, a run still drawing pictures is healthy and already spending a
|
|
203
|
+
little. Do not cancel it because nothing has happened yet. `workflow list`,
|
|
204
|
+
`workflow describe`, `video status`, `storyboard` and `pull` are free reads; run
|
|
205
|
+
them as often as you like. Never start a second run to "retry" without the user
|
|
206
|
+
asking; tell them what failed instead.
|
|
184
207
|
|
|
185
208
|
**`--auto-approve` does not cover either video gate.** It releases Checkpoint
|
|
186
209
|
boxes only (`convex/workflows.ts:7266` keys on `pauseReason === "checkpoint"`),
|
|
@@ -574,6 +597,11 @@ musicHeardScenes[] scene numbers whose clip came back with
|
|
|
574
597
|
music the video model baked in. A run with
|
|
575
598
|
the bed off can still list scenes here, and
|
|
576
599
|
those are the ones to listen to
|
|
600
|
+
pendingRedo[] scene numbers the app is redoing right
|
|
601
|
+
now. Their clip and words are left out,
|
|
602
|
+
because the file on the run is the take
|
|
603
|
+
being replaced: run `exodus video wait`,
|
|
604
|
+
then pull again
|
|
577
605
|
narration { file, timing }, or null on a per-scene run
|
|
578
606
|
cast[] one per identity still
|
|
579
607
|
characterId, name from the run's cast lock; null for an
|
|
@@ -928,8 +956,14 @@ the pieces:
|
|
|
928
956
|
keeps about 0.15 s after its last word and about 0.1 s before its first (0.25 s
|
|
929
957
|
when a different speaker answers), read from its `words.json`, so one clip
|
|
930
958
|
runs into the next like one conversation. It only ever shortens a clip's
|
|
931
|
-
silence and never cuts into a word. The ad's first
|
|
932
|
-
|
|
959
|
+
silence and never cuts into a word. The ad's first clip also opens about 0.1 s
|
|
960
|
+
before its first word, so a hum a clip makes before anyone speaks is not the
|
|
961
|
+
first thing heard. The ad's last tail, a narrated scene, a still and a
|
|
962
|
+
reaction beat keep their length.
|
|
963
|
+
- **It fades the audience reaction in and out.** A reaction beat fades in
|
|
964
|
+
across any quiet start before its laugh (a generated laugh clip can hum
|
|
965
|
+
there) and fades out over its last 0.4 s. Only a reaction clip longer than
|
|
966
|
+
its beat carries its own sound on under the next line.
|
|
933
967
|
- **It ignores the continuous master.** It reads only `scene-NN.voice` files;
|
|
934
968
|
it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
|
|
935
969
|
master down whole and cuts the pictures to it keeps the read intact.
|
|
@@ -944,7 +978,10 @@ the pieces:
|
|
|
944
978
|
ad, and the music bed underneath at low level. Output is H.264 + AAC in an MP4
|
|
945
979
|
with `-movflags +faststart`.
|
|
946
980
|
- Flags: `--out <file>`, `--skip 2,5`, `--no-music`. It prints what went in,
|
|
947
|
-
what was left out and why, and the upload command.
|
|
981
|
+
what was left out and why, and the upload command. When a clip hums or
|
|
982
|
+
clicks where nobody speaks at an edge the cut plays, it lists each one under
|
|
983
|
+
"Sound at the edges" with its time in the cut. That is a warning to listen
|
|
984
|
+
there; the cut is made anyway.
|
|
948
985
|
- Beside the MP4 it writes `cut.words.json` (every spoken word of the finished
|
|
949
986
|
ad with its start and end) and `cut.srt` (the same words grouped into subtitle
|
|
950
987
|
lines), named from the `--out` basename. Those times are on the finished ad's
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// bed go underneath, and one MP4 comes out ready to upload. A reaction
|
|
6
6
|
// cutaway is the exception: it is its own short beat in the spine right after
|
|
7
7
|
// its cued line ends, heard with its own sound while the speaker is silent,
|
|
8
|
-
//
|
|
8
|
+
// fading in across any quiet start and out rather than stopping dead.
|
|
9
9
|
//
|
|
10
10
|
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
|
|
11
11
|
//
|
|
@@ -71,6 +71,8 @@ export const REACTION_MAX_SEC = 1.5;
|
|
|
71
71
|
*/
|
|
72
72
|
export const REACTION_TAIL_SEC = 0.75;
|
|
73
73
|
export const REACTION_TAIL_GAIN = 0.35;
|
|
74
|
+
/** A reaction clip that ends with its beat fades out over this long instead (#2666). */
|
|
75
|
+
export const REACTION_FADE_OUT_SEC = 0.4;
|
|
74
76
|
/**
|
|
75
77
|
* The air a join keeps around its words (#2388): after one clip's last word, and
|
|
76
78
|
* before the next clip's first, less when the same speaker carries on than when
|
|
@@ -135,6 +137,13 @@ const LEVEL_WINDOW_SEC = 0.02;
|
|
|
135
137
|
const SOUND_DB = -35;
|
|
136
138
|
const QUIET_DB = -50;
|
|
137
139
|
const SETTLED_WINDOWS = 10;
|
|
140
|
+
/**
|
|
141
|
+
* #2666: in a gap gone quiet, a fainter sound counts when it stands this far
|
|
142
|
+
* over the gap's median level and reaches FAINT_SOUND_DB, so a grunt in near
|
|
143
|
+
* silence goes while a soft breath and room tone stay (scout trim.ts).
|
|
144
|
+
*/
|
|
145
|
+
const SOUND_OVER_FLOOR_DB = 25;
|
|
146
|
+
const FAINT_SOUND_DB = -45;
|
|
138
147
|
/** The directions that ask for a pause (scout clip-qc.ts PAUSE_DIRECTION, NO_PAUSE_DIRECTION, lineWrittenPause). */
|
|
139
148
|
const PAUSE_DIRECTION =
|
|
140
149
|
/\bpaus(e|es|ed|ing)\b|\bsilence\b|\(beat\)|\b(a|one|brief|short|quick|small|little|long)\s+beat\b|\bbeat\s*(,|before|after|then)/i;
|
|
@@ -221,7 +230,9 @@ export function sentenceGapCuts(words, lines, levels = null) {
|
|
|
221
230
|
* #2554: a sound between two words that holds no heard word, from where the
|
|
222
231
|
* earlier word fades under QUIET_DB to where SETTLED_WINDOWS of quiet resume
|
|
223
232
|
* before the later word, or null when there is none or it runs into the later
|
|
224
|
-
* word.
|
|
233
|
+
* word. A sound heard only against the gap's quiet (#2666) must come after the
|
|
234
|
+
* gap settles, so the earlier word's own release stays. Mirrors scout trim.ts
|
|
235
|
+
* soundBetween.
|
|
225
236
|
*/
|
|
226
237
|
function soundBetween(levels, fromSec, toSec) {
|
|
227
238
|
const { db, windowSec } = levels;
|
|
@@ -229,9 +240,13 @@ function soundBetween(levels, fromSec, toSec) {
|
|
|
229
240
|
const last = Math.min(windowAt(toSec), db.length);
|
|
230
241
|
let quiet = windowAt(fromSec);
|
|
231
242
|
while (quiet < last && db[quiet] >= QUIET_DB) quiet++;
|
|
243
|
+
const gap = db.slice(quiet, last).sort((a, b) => a - b);
|
|
244
|
+
const faint = Math.min(SOUND_DB, Math.max(FAINT_SOUND_DB, gap[gap.length >> 1] + SOUND_OVER_FLOOR_DB));
|
|
245
|
+
const calm = settledQuiet(db, quiet);
|
|
246
|
+
const loud = (i) => db[i] >= (calm >= 0 && i >= calm + SETTLED_WINDOWS ? faint : SOUND_DB);
|
|
232
247
|
let resumed = -1;
|
|
233
248
|
for (let i = quiet; i < last; i = resumed + SETTLED_WINDOWS) {
|
|
234
|
-
while (i < last &&
|
|
249
|
+
while (i < last && !loud(i)) i++;
|
|
235
250
|
if (i >= last) break;
|
|
236
251
|
const settled = settledQuiet(db, i);
|
|
237
252
|
if (settled < 0 || settled + SETTLED_WINDOWS > last) break;
|
|
@@ -264,11 +279,14 @@ export function levelsFromPcm(pcm, sampleRate) {
|
|
|
264
279
|
return { windowSec: LEVEL_WINDOW_SEC, db };
|
|
265
280
|
}
|
|
266
281
|
|
|
267
|
-
/** A clip's loudness windows, or null when its audio cannot be decoded. */
|
|
268
|
-
function clipLevels(file) {
|
|
282
|
+
/** A clip's loudness windows, or null when its audio cannot be decoded; `filter` shapes the sound first. */
|
|
283
|
+
function clipLevels(file, filter = null) {
|
|
269
284
|
const res = spawnSync(
|
|
270
285
|
"ffmpeg",
|
|
271
|
-
[
|
|
286
|
+
[
|
|
287
|
+
"-v", "error", "-i", file, "-map", "0:a:0", "-ac", "1", "-ar", "16000",
|
|
288
|
+
...(filter ? ["-af", filter] : []), "-f", "s16le", "-",
|
|
289
|
+
],
|
|
272
290
|
{ maxBuffer: 1 << 28 },
|
|
273
291
|
);
|
|
274
292
|
if (res.status !== 0 || !res.stdout?.length) return null;
|
|
@@ -421,10 +439,12 @@ export function cueEndInSegment(seg, counts, cueLineId) {
|
|
|
421
439
|
* Cut the dead air out of each join between clips that play their own sound: a
|
|
422
440
|
* clip's lead-in silence stacked on the previous clip's tail ran past a second
|
|
423
441
|
* (#2388). Each side comes back to a beat that reads as one conversation, a
|
|
424
|
-
* longer one when someone new answers. Only ever shortens, never into a word
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
442
|
+
* longer one when someone new answers. Only ever shortens, never into a word.
|
|
443
|
+
* The ad's first lead-in comes back to the same-speaker beat too, since a clip
|
|
444
|
+
* can hum before its first word (#2666); its last tail is left alone, and a
|
|
445
|
+
* clip whose closing line cues a reaction is not the ad's end, since the beat
|
|
446
|
+
* follows it. Trims land on whole frames so the picture and its sound stay the
|
|
447
|
+
* same length.
|
|
428
448
|
*
|
|
429
449
|
* The beat is measured from the clip's scripted words when the script is heard
|
|
430
450
|
* in order, and a cut stops short of a sound made up before or after the line
|
|
@@ -435,14 +455,14 @@ function trimJoins(spine, reactionCues, scriptLines) {
|
|
|
435
455
|
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
|
|
436
456
|
const prev = spine[k - 1];
|
|
437
457
|
const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
|
|
438
|
-
const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
|
|
458
|
+
const lead = sameSpeaker || !prev ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
|
|
439
459
|
const span = scriptedSpan(seg.words, seg.lineIds.map((id) => scriptLines.get(id)?.text));
|
|
440
460
|
const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
|
|
441
461
|
const before = span ? seg.words.slice(0, span.first) : [];
|
|
442
462
|
const after = span ? seg.words.slice(span.last + 1) : [];
|
|
443
463
|
const firstWord = Math.min(...line.map((w) => w.s));
|
|
444
464
|
const lastWord = Math.max(...line.map((w) => w.e));
|
|
445
|
-
let inSec =
|
|
465
|
+
let inSec = Math.max(0, floorFrame(firstWord - lead));
|
|
446
466
|
if (before.length > 0) {
|
|
447
467
|
const clear = ceilFrame(Math.max(...before.map((w) => w.e)) + EXTRA_CLEARANCE_SEC);
|
|
448
468
|
inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
|
|
@@ -520,17 +540,28 @@ function placeReaction(spine, counts, reaction) {
|
|
|
520
540
|
|
|
521
541
|
/**
|
|
522
542
|
* The sound a reaction beat carries under the next segment (#2406): the clip's
|
|
523
|
-
* own sound past the beat
|
|
524
|
-
*
|
|
543
|
+
* own sound past the beat (older 4 s pulls), or null when the clip ends with
|
|
544
|
+
* the beat and the beat fades itself out. Replaying the beat's last stretch
|
|
545
|
+
* there played its laugh twice, heard as a stutter (#2666).
|
|
525
546
|
*/
|
|
526
547
|
function reactionTail(seg, next) {
|
|
527
548
|
const clipLen = seg.clipSeconds ?? seg.seconds;
|
|
528
|
-
const
|
|
529
|
-
|
|
549
|
+
const fromSec = seg.inSec + seg.seconds;
|
|
550
|
+
if (clipLen - fromSec < REACTION_FADE_OUT_SEC) return null;
|
|
530
551
|
const seconds = Math.min(REACTION_TAIL_SEC, next.seconds, clipLen - fromSec);
|
|
531
552
|
return { atSec: next.startSec, fromSec, seconds, gain: REACTION_TAIL_GAIN };
|
|
532
553
|
}
|
|
533
554
|
|
|
555
|
+
/**
|
|
556
|
+
* #2666: how long a reaction beat fades in. A generated laugh clip can hum
|
|
557
|
+
* before the laugh starts, so the fade runs across that quiet start, up to its
|
|
558
|
+
* first sound; without the clip's loudness it is the plain edge fade.
|
|
559
|
+
*/
|
|
560
|
+
function quietStart(levels, seconds) {
|
|
561
|
+
const first = levels ? levels.db.findIndex((d) => d >= SOUND_DB) : -1;
|
|
562
|
+
return first > 0 ? Math.min(seconds, Math.max(EDGE_FADE_SEC, first * levels.windowSec)) : EDGE_FADE_SEC;
|
|
563
|
+
}
|
|
564
|
+
|
|
534
565
|
export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords, readLevels = () => null }) {
|
|
535
566
|
const plans = scenePlans(storyboard);
|
|
536
567
|
const counts = lineWordCounts(storyboard);
|
|
@@ -648,9 +679,11 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
648
679
|
total += seg.seconds;
|
|
649
680
|
}
|
|
650
681
|
spine.forEach((seg, k) => {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
682
|
+
if (!seg.reaction || seg.audio !== "clip") return;
|
|
683
|
+
seg.fadeInSec = quietStart(readLevels(seg.file), seg.seconds);
|
|
684
|
+
const tail = spine[k + 1] ? reactionTail(seg, spine[k + 1]) : null;
|
|
685
|
+
if (tail) seg.reaction.tail = tail;
|
|
686
|
+
else seg.fadeOutSec = Math.min(REACTION_FADE_OUT_SEC, seg.seconds);
|
|
654
687
|
});
|
|
655
688
|
|
|
656
689
|
for (const c of waiting.filter((w) => !w.reaction)) {
|
|
@@ -739,9 +772,12 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
739
772
|
filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
|
|
740
773
|
// A segment split around a reaction beat starts partway into its sound too.
|
|
741
774
|
const skipIn = inSec > 0.001 ? `atrim=start=${sec(inSec)},asetpts=PTS-STARTPTS,` : "";
|
|
775
|
+
const [fadeIn, fadeOut] = [seg.fadeInSec ?? EDGE_FADE_SEC, seg.fadeOutSec ?? EDGE_FADE_SEC];
|
|
776
|
+
// A cubic rise keeps a reaction's quiet start near silent until its laugh (#2666).
|
|
777
|
+
const rise = fadeIn > EDGE_FADE_SEC ? ":curve=cub" : "";
|
|
742
778
|
const fitLength =
|
|
743
|
-
`apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${
|
|
744
|
-
`afade=t=out:st=${sec(seg.seconds -
|
|
779
|
+
`apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${sec(fadeIn)}${rise},` +
|
|
780
|
+
`afade=t=out:st=${sec(seg.seconds - fadeOut)}:d=${sec(fadeOut)}`;
|
|
745
781
|
const fitSound = `${LOUDNORM},${fitLength}`;
|
|
746
782
|
if (seg.audio === "narration") {
|
|
747
783
|
const v = addInput("-i", resolve(seg.voice));
|
|
@@ -883,9 +919,12 @@ export function inTheCut(timeline) {
|
|
|
883
919
|
if (seg.reaction) {
|
|
884
920
|
const sound = seg.audio === "clip" ? "its own sound" : "silent: the clip has no sound";
|
|
885
921
|
const tail = seg.reaction.tail;
|
|
886
|
-
const
|
|
922
|
+
const fadeIn =
|
|
923
|
+
seg.fadeInSec > EDGE_FADE_SEC ? `, fading in over its quiet first ${seg.fadeInSec.toFixed(2)}s` : "";
|
|
924
|
+
const fadeOut = tail
|
|
887
925
|
? `, fading out under the next line at ${tail.atSec.toFixed(2)}s over ${tail.seconds.toFixed(2)}s`
|
|
888
|
-
: "";
|
|
926
|
+
: seg.fadeOutSec ? `, fading out over its last ${seg.fadeOutSec.toFixed(2)}s` : "";
|
|
927
|
+
const fades = fadeIn + fadeOut;
|
|
889
928
|
rows.push({
|
|
890
929
|
n: seg.sceneIndex,
|
|
891
930
|
line:
|
|
@@ -913,6 +952,108 @@ export function inTheCut(timeline) {
|
|
|
913
952
|
return rows.sort((a, b) => a.n - b.n).map((r) => r.line);
|
|
914
953
|
}
|
|
915
954
|
|
|
955
|
+
/**
|
|
956
|
+
* #2666: a generated clip can hum or click where nobody speaks, and the cut
|
|
957
|
+
* plays that stretch with nothing over it. A hum is HUM_BAND's share of the
|
|
958
|
+
* sound (the 1 kHz whine heard before a first word) holding for HUM_MIN_SEC
|
|
959
|
+
* over HUM_FLOOR_DB; a click is a sound of at most CLICK_MAX_WINDOWS with
|
|
960
|
+
* quiet on both sides, loud as the gap cutter reads a sound in near silence.
|
|
961
|
+
*/
|
|
962
|
+
export const HUM_BAND = "highpass=f=800,highpass=f=800,lowpass=f=1500,lowpass=f=1500";
|
|
963
|
+
// White noise measures about -13.5 dB through HUM_BAND at 16 kHz (-10.6 dB on
|
|
964
|
+
// paper), and the four hums #2666 was calibrated on held -6 to -10 dB.
|
|
965
|
+
const HUM_SHARE_DB = -10;
|
|
966
|
+
const HUM_FLOOR_DB = -70;
|
|
967
|
+
const HUM_MIN_SEC = 0.16;
|
|
968
|
+
const CLICK_MAX_WINDOWS = 3;
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Where the cut plays a clip's sound with no word in it, in the clip's own
|
|
972
|
+
* time: before a dialogue clip's first word, after its last, and a reaction's
|
|
973
|
+
* quiet start (under its cubic fade-in when the cut fades it).
|
|
974
|
+
*/
|
|
975
|
+
function edgeStretches(seg, levels) {
|
|
976
|
+
const inSec = seg.inSec ?? 0;
|
|
977
|
+
if (seg.reaction) {
|
|
978
|
+
const quiet = quietStart(levels, seg.seconds);
|
|
979
|
+
if (quiet <= EDGE_FADE_SEC) return [];
|
|
980
|
+
const fadeIn = seg.fadeInSec ?? 0;
|
|
981
|
+
return [{ side: "before its laugh", from: inSec, to: inSec + quiet, at: seg.startSec, fadeIn }];
|
|
982
|
+
}
|
|
983
|
+
if (!seg.words?.length) return [];
|
|
984
|
+
// inSec and the words are on the clip's timeline with its sentence gaps
|
|
985
|
+
// taken out; the levels are the file's own, so each edge goes back through
|
|
986
|
+
// the cuts before it.
|
|
987
|
+
const raw = (t) => {
|
|
988
|
+
let at = t;
|
|
989
|
+
for (const c of seg.cuts ?? []) if (c.s <= at) at += c.e - c.s;
|
|
990
|
+
return at;
|
|
991
|
+
};
|
|
992
|
+
const first = seg.words[0].s;
|
|
993
|
+
const last = Math.max(...seg.words.map((w) => w.e));
|
|
994
|
+
const shown = seg.seconds - (seg.holdSec ?? 0);
|
|
995
|
+
return [
|
|
996
|
+
{ side: "before its first word", from: raw(inSec), to: raw(inSec + first), at: seg.startSec, fadeIn: 0 },
|
|
997
|
+
{
|
|
998
|
+
side: "after its last word", from: raw(inSec + last), to: raw(inSec + shown),
|
|
999
|
+
at: seg.startSec + last, fadeIn: 0,
|
|
1000
|
+
},
|
|
1001
|
+
];
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* The hums and clicks the cut plays at the edges of its clips, one line each,
|
|
1006
|
+
* for the caller to listen to. It never changes the cut. readEdgeLevels(file)
|
|
1007
|
+
* gives { windowSec, db, bandDb } from time 0, or null.
|
|
1008
|
+
*/
|
|
1009
|
+
export function edgeSounds(timeline, readEdgeLevels) {
|
|
1010
|
+
const found = [];
|
|
1011
|
+
for (const seg of timeline.spine) {
|
|
1012
|
+
if (seg.source !== "clip" || seg.audio !== "clip") continue;
|
|
1013
|
+
const levels = readEdgeLevels(seg.file);
|
|
1014
|
+
if (!levels) continue;
|
|
1015
|
+
const { windowSec, db, bandDb } = levels;
|
|
1016
|
+
for (const stretch of edgeStretches(seg, levels)) {
|
|
1017
|
+
const from = Math.ceil(stretch.from / windowSec - 1e-9);
|
|
1018
|
+
const to = Math.min(db.length, Math.floor(stretch.to / windowSec + 1e-9));
|
|
1019
|
+
if (to - from < 2) continue;
|
|
1020
|
+
const heard = [];
|
|
1021
|
+
for (let i = from; i < to; i++) {
|
|
1022
|
+
const into = (i + 0.5) * windowSec - stretch.from;
|
|
1023
|
+
const faded = stretch.fadeIn > 0 && into < stretch.fadeIn;
|
|
1024
|
+
const gain = faded ? 60 * Math.log10(Math.max(1e-3, into / stretch.fadeIn)) : 0;
|
|
1025
|
+
heard.push({ i, db: db[i] + gain, share: bandDb[i] - db[i] });
|
|
1026
|
+
}
|
|
1027
|
+
const cutAt = (i) => stretch.at + i * windowSec - stretch.from;
|
|
1028
|
+
const where = `scene ${seg.sceneIndex}, ${stretch.side}`;
|
|
1029
|
+
let run = 0;
|
|
1030
|
+
let longest = { length: 0, end: 0 };
|
|
1031
|
+
heard.forEach((w, k) => {
|
|
1032
|
+
run = w.db >= HUM_FLOOR_DB && w.share >= HUM_SHARE_DB ? run + 1 : 0;
|
|
1033
|
+
if (run > longest.length) longest = { length: run, end: k };
|
|
1034
|
+
});
|
|
1035
|
+
if (longest.length * windowSec >= HUM_MIN_SEC - 1e-9) {
|
|
1036
|
+
const start = heard[longest.end - longest.length + 1].i;
|
|
1037
|
+
found.push(
|
|
1038
|
+
`${where}: a hum for ${(longest.length * windowSec).toFixed(2)}s at ${cutAt(start).toFixed(2)}s in the cut`,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
const sorted = heard.map((w) => w.db).sort((a, b) => a - b);
|
|
1042
|
+
const loud = Math.max(FAINT_SOUND_DB, sorted[sorted.length >> 1] + SOUND_OVER_FLOOR_DB);
|
|
1043
|
+
for (let k = 1; k < heard.length - 1; k++) {
|
|
1044
|
+
if (heard[k].db < loud || heard[k - 1].db >= QUIET_DB) continue;
|
|
1045
|
+
let end = k;
|
|
1046
|
+
while (end < heard.length && heard[end].db >= loud) end++;
|
|
1047
|
+
if (end < heard.length && heard[end].db < QUIET_DB && end - k <= CLICK_MAX_WINDOWS) {
|
|
1048
|
+
found.push(`${where}: a click at ${cutAt(heard[k].i).toFixed(2)}s in the cut`);
|
|
1049
|
+
}
|
|
1050
|
+
k = end;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return found;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
916
1057
|
export function noWordTimes(timeline) {
|
|
917
1058
|
const rows = timeline.spine
|
|
918
1059
|
.filter((seg) => !seg.words || seg.words.length === 0)
|
|
@@ -1048,6 +1189,15 @@ function main() {
|
|
|
1048
1189
|
}
|
|
1049
1190
|
const silent = noWordTimes(timeline);
|
|
1050
1191
|
if (silent.length > 0) console.log(`No word times: ${silent.join(", ")}`);
|
|
1192
|
+
const edges = edgeSounds(timeline, (file) => {
|
|
1193
|
+
const levels = clipLevels(here(file));
|
|
1194
|
+
const band = levels && clipLevels(here(file), HUM_BAND);
|
|
1195
|
+
return band ? { ...levels, bandDb: band.db } : null;
|
|
1196
|
+
});
|
|
1197
|
+
if (edges.length > 0) {
|
|
1198
|
+
console.log("Sound at the edges (listen before uploading; the cut is unchanged):");
|
|
1199
|
+
for (const line of edges) console.log(" " + line);
|
|
1200
|
+
}
|
|
1051
1201
|
console.log(`Room tone: under the whole ad. Music bed: ${music ?? "none"}`);
|
|
1052
1202
|
console.log(`Upload it: exodus video upload ${manifest.runId} --file ${out}`);
|
|
1053
1203
|
}
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -63,6 +63,7 @@ export type ArtifactSubset = {
|
|
|
63
63
|
type: "image";
|
|
64
64
|
imageUrl?: string;
|
|
65
65
|
storageId?: string;
|
|
66
|
+
anchor?: "set-plate" | "product-sheet";
|
|
66
67
|
} | {
|
|
67
68
|
type: "video";
|
|
68
69
|
sceneIndex?: number;
|
|
@@ -118,6 +119,7 @@ export interface VideoRunNode {
|
|
|
118
119
|
hasRejectedDraft?: boolean;
|
|
119
120
|
outputs?: ArtifactSubset[];
|
|
120
121
|
missingScenes?: MissingScene[];
|
|
122
|
+
autoRedoScenes?: number[];
|
|
121
123
|
}
|
|
122
124
|
export interface MissingScene {
|
|
123
125
|
sceneIndex: number;
|
|
@@ -151,8 +153,29 @@ export interface VideoRun {
|
|
|
151
153
|
provenance?: RunProvenance;
|
|
152
154
|
musicBed?: boolean;
|
|
153
155
|
videoChoice?: RunVideoChoice;
|
|
156
|
+
storyboardAutoApproval?: {
|
|
157
|
+
outcome: "approved" | "held";
|
|
158
|
+
at: number;
|
|
159
|
+
reason?: string;
|
|
160
|
+
};
|
|
154
161
|
workflowId?: string;
|
|
155
162
|
moduleOwned?: boolean;
|
|
163
|
+
director?: RunDirector;
|
|
164
|
+
}
|
|
165
|
+
export interface RunDirector {
|
|
166
|
+
on: boolean;
|
|
167
|
+
runSetting: boolean | null;
|
|
168
|
+
workspaceOn: boolean;
|
|
169
|
+
status: "reviewing" | "waiting" | "done" | "stopped" | null;
|
|
170
|
+
redosUsed: number;
|
|
171
|
+
redoCap: number;
|
|
172
|
+
spendUsd: number;
|
|
173
|
+
spendCapUsd: number;
|
|
174
|
+
log: {
|
|
175
|
+
at: number;
|
|
176
|
+
phase: "storyboard" | "final-watch";
|
|
177
|
+
text: string;
|
|
178
|
+
}[];
|
|
156
179
|
}
|
|
157
180
|
export declare function asVideoRun(data: unknown): VideoRun;
|
|
158
181
|
export interface NodeItem {
|
|
@@ -222,11 +245,13 @@ export declare function videoApiError(res: ApiResponse<unknown>): string;
|
|
|
222
245
|
export type RunStop = {
|
|
223
246
|
at: "running";
|
|
224
247
|
stage: string;
|
|
248
|
+
autoRedoScenes?: number[];
|
|
225
249
|
} | {
|
|
226
250
|
at: "storyboard-gate";
|
|
227
251
|
nodeId?: string;
|
|
228
252
|
framesNodeId?: string;
|
|
229
253
|
showAd?: true;
|
|
254
|
+
heldReason?: string;
|
|
230
255
|
} | {
|
|
231
256
|
at: "final-watch";
|
|
232
257
|
missingScenes?: MissingScene[];
|
|
@@ -254,6 +279,7 @@ export type ResolvedStop = Exclude<RunStop, {
|
|
|
254
279
|
});
|
|
255
280
|
export declare function classifyRun(run: VideoRun): RunStop;
|
|
256
281
|
export declare function hasAttachedCut(items: NodeItem[]): boolean;
|
|
282
|
+
export declare function scenesBeingRedone(run: VideoRun, items: NodeItem[]): number[];
|
|
257
283
|
export declare function resolveStop(stop: RunStop, cutAttached: boolean | null): ResolvedStop;
|
|
258
284
|
export declare function resolveStopAtPark(stop: RunStop, runId: string, deps: Pick<VideoDeps, "get">): Promise<ResolvedStop>;
|
|
259
285
|
export declare function stageWord(stage: string): string;
|
|
@@ -322,9 +348,12 @@ export interface VideoManifest {
|
|
|
322
348
|
dashboardUrl: string;
|
|
323
349
|
storyboard: string | null;
|
|
324
350
|
reference: string | null;
|
|
351
|
+
productSheet: string | null;
|
|
352
|
+
setPlate: string | null;
|
|
325
353
|
music: string | null;
|
|
326
354
|
musicBed: MusicBedState;
|
|
327
355
|
musicHeardScenes: number[];
|
|
356
|
+
pendingRedo: number[];
|
|
328
357
|
cast: ManifestCastRef[];
|
|
329
358
|
narration: {
|
|
330
359
|
file: string;
|
|
@@ -371,6 +400,8 @@ export interface ScriptStartOptions {
|
|
|
371
400
|
videoModel?: string;
|
|
372
401
|
voiceMode?: VoiceMode;
|
|
373
402
|
music?: boolean;
|
|
403
|
+
reviewStoryboard?: true;
|
|
404
|
+
director?: true;
|
|
374
405
|
wait: boolean;
|
|
375
406
|
json: boolean;
|
|
376
407
|
}
|
|
@@ -402,6 +433,12 @@ export declare function waitFlow(runId: string, opts: {
|
|
|
402
433
|
maxPolls?: number;
|
|
403
434
|
}, deps: VideoDeps): Promise<FlowResult>;
|
|
404
435
|
export declare function statusFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
436
|
+
export declare function directorLines(director: RunDirector): string[];
|
|
437
|
+
export type DirectorSwitch = boolean | null;
|
|
438
|
+
export declare function parseDirectorSwitch(word: string | undefined): {
|
|
439
|
+
on: DirectorSwitch;
|
|
440
|
+
} | null;
|
|
441
|
+
export declare function directorFlow(runId: string, on: DirectorSwitch, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
405
442
|
export declare function rejectedDraftFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
406
443
|
export declare function storyboardFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
407
444
|
export declare function approveFlow(runId: string, opts: {
|
package/dist/commands/video.js
CHANGED
|
@@ -103,7 +103,8 @@ Usage:
|
|
|
103
103
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
104
104
|
exodus video pull <runId> --out <dir> [--json]
|
|
105
105
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
106
|
-
exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--wait] [--json]
|
|
106
|
+
exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--review-storyboard] [--director] [--wait] [--json]
|
|
107
|
+
exodus video director <runId> <on|off|follow> [--json]
|
|
107
108
|
|
|
108
109
|
Options:
|
|
109
110
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
@@ -144,7 +145,15 @@ Options:
|
|
|
144
145
|
--music Put a music bed under the ad (start). An ad has no music
|
|
145
146
|
unless you ask for it here
|
|
146
147
|
--no-music Leave the music bed off (start), which it already is
|
|
147
|
-
--
|
|
148
|
+
--review-storyboard Stop at the storyboard so you can approve it yourself
|
|
149
|
+
(start, with --conceit). By default the app approves its
|
|
150
|
+
own storyboard when its checks pass
|
|
151
|
+
--director Turn the trial director on for this ad (start, with
|
|
152
|
+
--conceit). Admins only. To switch it on or off later:
|
|
153
|
+
exodus video director <runId> on|off|follow, where
|
|
154
|
+
follow goes back to the brand's own setting
|
|
155
|
+
--wait Wait until the run needs you (start): at the
|
|
156
|
+
storyboard, or once every piece is made
|
|
148
157
|
--json Machine-readable output
|
|
149
158
|
--help, -h Print this help
|
|
150
159
|
|
|
@@ -166,10 +175,12 @@ Examples:
|
|
|
166
175
|
exodus video voices run_123 --from voices.json
|
|
167
176
|
exodus video pull run_123 --out ./ad-run_123
|
|
168
177
|
exodus video upload run_123 --file ./cut.mp4
|
|
178
|
+
exodus video director run_123 on
|
|
169
179
|
`.trim();
|
|
170
180
|
const SHOWS_PATH = "/api/v2/shows";
|
|
171
181
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
172
182
|
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
183
|
+
const DIRECTOR_PATH = "/api/v2/video/director";
|
|
173
184
|
const RUN_PATH = "/api/v2/workflow";
|
|
174
185
|
const ITEMS_PATH = "/api/v2/workflow/items";
|
|
175
186
|
const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
|
|
@@ -324,6 +335,14 @@ function parkedAtFinalWatch(run) {
|
|
|
324
335
|
return (!parkedByBuilderCheckpoint(run) &&
|
|
325
336
|
run.nodes.some((n) => n.kind === "video" && n.status === "done"));
|
|
326
337
|
}
|
|
338
|
+
function heldReason(run) {
|
|
339
|
+
const verdict = run.storyboardAutoApproval;
|
|
340
|
+
if (verdict?.outcome !== "held")
|
|
341
|
+
return undefined;
|
|
342
|
+
const reason = verdict.reason?.trim() ||
|
|
343
|
+
"The app didn't approve this storyboard by itself because its checks found a problem.";
|
|
344
|
+
return `${reason} (checked ${new Date(verdict.at).toISOString()})`;
|
|
345
|
+
}
|
|
327
346
|
export function classifyRun(run) {
|
|
328
347
|
if (run.status === "failed")
|
|
329
348
|
return { at: "failed", error: run.error };
|
|
@@ -343,6 +362,7 @@ export function classifyRun(run) {
|
|
|
343
362
|
}
|
|
344
363
|
const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
|
|
345
364
|
if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
|
|
365
|
+
const held = heldReason(run);
|
|
346
366
|
return {
|
|
347
367
|
at: "storyboard-gate",
|
|
348
368
|
nodeId: pausedNode.nodeId,
|
|
@@ -350,9 +370,15 @@ export function classifyRun(run) {
|
|
|
350
370
|
? { framesNodeId: pausedNode.nodeId }
|
|
351
371
|
: {}),
|
|
352
372
|
...(isShowAd(run) ? { showAd: true } : {}),
|
|
373
|
+
...(held ? { heldReason: held } : {}),
|
|
353
374
|
};
|
|
354
375
|
}
|
|
355
376
|
if (parkedAtFinalWatch(run)) {
|
|
377
|
+
const autoRedoScenes = run.nodes
|
|
378
|
+
.filter((n) => n.kind === "video")
|
|
379
|
+
.flatMap((n) => n.autoRedoScenes ?? []);
|
|
380
|
+
if (autoRedoScenes.length > 0)
|
|
381
|
+
return { at: "running", stage: "video", autoRedoScenes };
|
|
356
382
|
const missingScenes = run.nodes
|
|
357
383
|
.filter((n) => n.kind === "video")
|
|
358
384
|
.flatMap((n) => n.missingScenes ?? []);
|
|
@@ -371,6 +397,18 @@ export function classifyRun(run) {
|
|
|
371
397
|
export function hasAttachedCut(items) {
|
|
372
398
|
return items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
373
399
|
}
|
|
400
|
+
export function scenesBeingRedone(run, items) {
|
|
401
|
+
const scenes = new Set(run.nodes.filter((n) => n.kind === "video").flatMap((n) => n.autoRedoScenes ?? []));
|
|
402
|
+
for (const i of items) {
|
|
403
|
+
if (i.itemKind === "clip" &&
|
|
404
|
+
i.status === "running" &&
|
|
405
|
+
(i.attempt ?? 1) > 1 &&
|
|
406
|
+
i.staleClaim !== true) {
|
|
407
|
+
scenes.add(i.sceneIndex);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return [...scenes].sort((a, b) => a - b);
|
|
411
|
+
}
|
|
374
412
|
export function resolveStop(stop, cutAttached) {
|
|
375
413
|
if (stop.at !== "final-watch")
|
|
376
414
|
return stop;
|
|
@@ -444,6 +482,7 @@ function missingSceneLines(missing, framesNodeId, runId, runUrl) {
|
|
|
444
482
|
export function stopLines(stop, runId, runUrl) {
|
|
445
483
|
if (stop.at === "storyboard-gate") {
|
|
446
484
|
const lines = [
|
|
485
|
+
...(stop.heldReason ? [stop.heldReason] : []),
|
|
447
486
|
"Parked: the storyboard is waiting for your yes.",
|
|
448
487
|
`Read it: exodus video storyboard ${runId}`,
|
|
449
488
|
`Approve it: exodus video approve ${runId}`,
|
|
@@ -515,8 +554,15 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
515
554
|
`Open it: ${runUrl}`,
|
|
516
555
|
];
|
|
517
556
|
}
|
|
557
|
+
if (stop.autoRedoScenes?.length)
|
|
558
|
+
return [autoRedoLine(stop.autoRedoScenes)];
|
|
518
559
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
519
560
|
}
|
|
561
|
+
function autoRedoLine(scenes) {
|
|
562
|
+
const one = scenes.length === 1;
|
|
563
|
+
return (`Working: the app is redoing ${scenesPhrase(scenes)} on ${one ? "its" : "their"} own because ` +
|
|
564
|
+
`${one ? "it" : "they"} repeated words. The ad isn't ready to watch until ${one ? "it lands" : "they land"}.`);
|
|
565
|
+
}
|
|
520
566
|
export function failedStoryboardNode(run) {
|
|
521
567
|
return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
|
|
522
568
|
}
|
|
@@ -742,13 +788,19 @@ export function planPull(run, items, opts) {
|
|
|
742
788
|
}
|
|
743
789
|
}
|
|
744
790
|
let reference = null;
|
|
791
|
+
let productSheet = null;
|
|
792
|
+
let setPlate = null;
|
|
745
793
|
for (const artifact of outputsOfNodeKind(run, "reference")) {
|
|
746
794
|
if (artifact.type !== "image" || !artifact.imageUrl)
|
|
747
795
|
continue;
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
796
|
+
const stem = artifact.anchor ?? "reference";
|
|
797
|
+
const download = { file: `${stem}.${extFor(artifact.imageUrl, "image")}`, url: artifact.imageUrl };
|
|
798
|
+
if (artifact.anchor === "product-sheet")
|
|
799
|
+
productSheet = download;
|
|
800
|
+
else if (artifact.anchor === "set-plate")
|
|
801
|
+
setPlate = download;
|
|
802
|
+
else if (!artifact.anchor)
|
|
803
|
+
reference = download;
|
|
752
804
|
}
|
|
753
805
|
const keyframeByScene = new Map();
|
|
754
806
|
for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
|
|
@@ -852,6 +904,9 @@ export function planPull(run, items, opts) {
|
|
|
852
904
|
continue;
|
|
853
905
|
keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
|
|
854
906
|
}
|
|
907
|
+
const pendingRedo = scenesBeingRedone(run, items);
|
|
908
|
+
for (const sceneIndex of pendingRedo)
|
|
909
|
+
clipByScene.delete(sceneIndex);
|
|
855
910
|
const sceneIndexes = [
|
|
856
911
|
...new Set([
|
|
857
912
|
...clipByScene.keys(),
|
|
@@ -912,6 +967,8 @@ export function planPull(run, items, opts) {
|
|
|
912
967
|
const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
|
|
913
968
|
const downloads = [
|
|
914
969
|
...(reference ? [reference] : []),
|
|
970
|
+
...(productSheet ? [productSheet] : []),
|
|
971
|
+
...(setPlate ? [setPlate] : []),
|
|
915
972
|
...keyframeByScene.values(),
|
|
916
973
|
...[...voiceByScene.values()].map((v) => v.download),
|
|
917
974
|
...(narrationDownload ? [narrationDownload] : []),
|
|
@@ -928,11 +985,14 @@ export function planPull(run, items, opts) {
|
|
|
928
985
|
dashboardUrl: reviewUrl(opts.dashboardUrl, run),
|
|
929
986
|
storyboard,
|
|
930
987
|
reference: reference?.file ?? null,
|
|
988
|
+
productSheet: productSheet?.file ?? null,
|
|
989
|
+
setPlate: setPlate?.file ?? null,
|
|
931
990
|
music: music?.file ?? null,
|
|
932
991
|
musicBed: musicBedState(run),
|
|
933
992
|
musicHeardScenes: scenes
|
|
934
993
|
.filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
|
|
935
994
|
.map((scene) => scene.sceneIndex),
|
|
995
|
+
pendingRedo,
|
|
936
996
|
cast,
|
|
937
997
|
narration: narrationDownload
|
|
938
998
|
? { file: narrationDownload.file, timing: "narration.json" }
|
|
@@ -959,6 +1019,10 @@ export function markPullFailure(manifest, failure) {
|
|
|
959
1019
|
manifest.storyboard = null;
|
|
960
1020
|
if (manifest.reference === failure.file)
|
|
961
1021
|
manifest.reference = null;
|
|
1022
|
+
if (manifest.productSheet === failure.file)
|
|
1023
|
+
manifest.productSheet = null;
|
|
1024
|
+
if (manifest.setPlate === failure.file)
|
|
1025
|
+
manifest.setPlate = null;
|
|
962
1026
|
if (manifest.music === failure.file)
|
|
963
1027
|
manifest.music = null;
|
|
964
1028
|
for (const ref of manifest.cast) {
|
|
@@ -1188,6 +1252,27 @@ export function planVideoStart(flags, occurrences) {
|
|
|
1188
1252
|
return { kind: "usage", line: voiceFlag.line };
|
|
1189
1253
|
const videoModel = videoModelFlag.value;
|
|
1190
1254
|
const voiceMode = voiceFlag.value;
|
|
1255
|
+
const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
|
|
1256
|
+
if (showId && reviewStoryboard) {
|
|
1257
|
+
return {
|
|
1258
|
+
kind: "usage",
|
|
1259
|
+
line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
const directorFlags = occurrences.filter((o) => o.flag === "director");
|
|
1263
|
+
if (directorFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
|
|
1264
|
+
return {
|
|
1265
|
+
kind: "usage",
|
|
1266
|
+
line: "--director takes no value. Leave it off to start without the director.",
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
const director = directorFlags.length > 0;
|
|
1270
|
+
if (showId && director) {
|
|
1271
|
+
return {
|
|
1272
|
+
kind: "usage",
|
|
1273
|
+
line: "--director works with --conceit, not --show.",
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1191
1276
|
if (showId && (videoModel || voiceMode)) {
|
|
1192
1277
|
return {
|
|
1193
1278
|
kind: "usage",
|
|
@@ -1250,6 +1335,10 @@ export function planVideoStart(flags, occurrences) {
|
|
|
1250
1335
|
opts.voiceMode = voiceMode;
|
|
1251
1336
|
if (musicChoice.music !== undefined)
|
|
1252
1337
|
opts.music = musicChoice.music;
|
|
1338
|
+
if (reviewStoryboard)
|
|
1339
|
+
opts.reviewStoryboard = true;
|
|
1340
|
+
if (director)
|
|
1341
|
+
opts.director = true;
|
|
1253
1342
|
return { kind: "script", opts };
|
|
1254
1343
|
}
|
|
1255
1344
|
function isRecord(value) {
|
|
@@ -1279,7 +1368,7 @@ function startedVideoChoice(value) {
|
|
|
1279
1368
|
return null;
|
|
1280
1369
|
return { videoModel: value.videoModel, voiceMode: value.voiceMode };
|
|
1281
1370
|
}
|
|
1282
|
-
function scriptStartedLines(runId, url, card, video) {
|
|
1371
|
+
function scriptStartedLines(runId, url, card, video, director) {
|
|
1283
1372
|
return [
|
|
1284
1373
|
`Started ad run ${runId}`,
|
|
1285
1374
|
...(card
|
|
@@ -1291,6 +1380,7 @@ function scriptStartedLines(runId, url, card, video) {
|
|
|
1291
1380
|
]
|
|
1292
1381
|
: ["The server did not include a resolved card."]),
|
|
1293
1382
|
...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
|
|
1383
|
+
...(director ? ["Director: on for this ad"] : []),
|
|
1294
1384
|
`Watch it: ${url}`,
|
|
1295
1385
|
];
|
|
1296
1386
|
}
|
|
@@ -1337,6 +1427,8 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1337
1427
|
...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
|
|
1338
1428
|
...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
|
|
1339
1429
|
...(opts.music === true ? { music: true } : {}),
|
|
1430
|
+
...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
|
|
1431
|
+
...(opts.director ? { director: true } : {}),
|
|
1340
1432
|
});
|
|
1341
1433
|
if (!res.ok)
|
|
1342
1434
|
return errorResult(res, opts.json);
|
|
@@ -1354,9 +1446,14 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1354
1446
|
lines: opts.json
|
|
1355
1447
|
? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
|
|
1356
1448
|
: [
|
|
1357
|
-
...scriptStartedLines(runId, url, card, video),
|
|
1449
|
+
...scriptStartedLines(runId, url, card, video, opts.director === true),
|
|
1358
1450
|
"",
|
|
1359
|
-
|
|
1451
|
+
...(opts.reviewStoryboard
|
|
1452
|
+
? ["Wait for the storyboard here instead: exodus video start … --wait"]
|
|
1453
|
+
: [
|
|
1454
|
+
"The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
|
|
1455
|
+
"Wait here until it needs you: exodus video start … --wait",
|
|
1456
|
+
]),
|
|
1360
1457
|
`Or check in whenever: exodus video status ${runId}`,
|
|
1361
1458
|
],
|
|
1362
1459
|
};
|
|
@@ -1366,7 +1463,7 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1366
1463
|
return waitJsonWithCard(waited, card, video);
|
|
1367
1464
|
return {
|
|
1368
1465
|
code: waited.code,
|
|
1369
|
-
lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
|
|
1466
|
+
lines: [...scriptStartedLines(runId, url, card, video, opts.director === true), "", ...waited.lines],
|
|
1370
1467
|
};
|
|
1371
1468
|
}
|
|
1372
1469
|
const POLL_INTERVAL_MS = 5000;
|
|
@@ -1375,7 +1472,8 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
1375
1472
|
const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
|
|
1376
1473
|
const maxPolls = opts.maxPolls ?? MAX_POLLS;
|
|
1377
1474
|
const lines = [];
|
|
1378
|
-
let
|
|
1475
|
+
let lastProgress = null;
|
|
1476
|
+
let redoSeen = false;
|
|
1379
1477
|
for (let poll = 0; poll < maxPolls; poll++) {
|
|
1380
1478
|
if (poll > 0)
|
|
1381
1479
|
await deps.sleep(interval);
|
|
@@ -1387,14 +1485,35 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
1387
1485
|
const run = asVideoRun(res.data);
|
|
1388
1486
|
const stop = classifyRun(run);
|
|
1389
1487
|
if (stop.at === "running") {
|
|
1390
|
-
|
|
1391
|
-
|
|
1488
|
+
const progress = stop.autoRedoScenes?.length
|
|
1489
|
+
? autoRedoLine(stop.autoRedoScenes)
|
|
1490
|
+
: `Working: ${stageWord(stop.stage)}…`;
|
|
1491
|
+
if (progress !== lastProgress) {
|
|
1492
|
+
lastProgress = progress;
|
|
1392
1493
|
if (!opts.json)
|
|
1393
|
-
lines.push(
|
|
1494
|
+
lines.push(progress);
|
|
1394
1495
|
}
|
|
1395
1496
|
continue;
|
|
1396
1497
|
}
|
|
1397
|
-
|
|
1498
|
+
let items = null;
|
|
1499
|
+
if (stop.at === "final-watch") {
|
|
1500
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1501
|
+
items = itemsRes.ok ? (itemsRes.data.items ?? []) : null;
|
|
1502
|
+
if (items === null && redoSeen)
|
|
1503
|
+
continue;
|
|
1504
|
+
const redoing = items ? scenesBeingRedone(run, items) : [];
|
|
1505
|
+
redoSeen = redoing.length > 0;
|
|
1506
|
+
if (redoSeen) {
|
|
1507
|
+
const progress = `Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`;
|
|
1508
|
+
if (progress !== lastProgress) {
|
|
1509
|
+
lastProgress = progress;
|
|
1510
|
+
if (!opts.json)
|
|
1511
|
+
lines.push(progress);
|
|
1512
|
+
}
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const resolved = resolveStop(stop, items ? hasAttachedCut(items) : null);
|
|
1398
1517
|
if (opts.json) {
|
|
1399
1518
|
return {
|
|
1400
1519
|
code: stop.at === "failed" ? 1 : 0,
|
|
@@ -1469,6 +1588,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1469
1588
|
? { storyboardNodeId: failedStoryboard.nodeId }
|
|
1470
1589
|
: {}),
|
|
1471
1590
|
...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
|
|
1591
|
+
...(run.director ? { director: run.director } : {}),
|
|
1472
1592
|
}),
|
|
1473
1593
|
],
|
|
1474
1594
|
};
|
|
@@ -1518,6 +1638,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1518
1638
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
1519
1639
|
...planFailureLines(failedStoryboard, runId),
|
|
1520
1640
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
1641
|
+
...(run.director ? ["", ...directorLines(run.director)] : []),
|
|
1521
1642
|
];
|
|
1522
1643
|
if (byScene.size === 0) {
|
|
1523
1644
|
lines.push("", "No scenes yet — this run hasn't made anything to look at.");
|
|
@@ -1580,6 +1701,72 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1580
1701
|
: "Final cut: not uploaded yet.");
|
|
1581
1702
|
return { code: 0, lines };
|
|
1582
1703
|
}
|
|
1704
|
+
const DIRECTOR_STATUS_WORDS = {
|
|
1705
|
+
reviewing: "Watching this run",
|
|
1706
|
+
waiting: "Waiting for redos to finish",
|
|
1707
|
+
done: "Done: ready for your approval",
|
|
1708
|
+
stopped: "Stopped: see the last note",
|
|
1709
|
+
};
|
|
1710
|
+
const DIRECTOR_PHASE_WORDS = {
|
|
1711
|
+
storyboard: "Storyboard",
|
|
1712
|
+
"final-watch": "Final watch",
|
|
1713
|
+
};
|
|
1714
|
+
const DIRECTOR_LOG_LINES = 3;
|
|
1715
|
+
export function directorLines(director) {
|
|
1716
|
+
const switchWords = director.runSetting === null
|
|
1717
|
+
? `following the brand's setting (${director.workspaceOn ? "on" : "off"})`
|
|
1718
|
+
: "switched for this ad";
|
|
1719
|
+
const log = director.log.slice(-DIRECTOR_LOG_LINES);
|
|
1720
|
+
return [
|
|
1721
|
+
`Director: ${director.on ? "on" : "off"}, ${switchWords}`,
|
|
1722
|
+
...(director.on
|
|
1723
|
+
? [` ${DIRECTOR_STATUS_WORDS[director.status ?? "reviewing"]}`]
|
|
1724
|
+
: []),
|
|
1725
|
+
` Redos: ${director.redosUsed} of ${director.redoCap}. Spent: $${director.spendUsd.toFixed(2)} of $${director.spendCapUsd.toFixed(2)}`,
|
|
1726
|
+
...(log.length > 0
|
|
1727
|
+
? [
|
|
1728
|
+
" Latest notes:",
|
|
1729
|
+
...log.map((entry) => ` ${DIRECTOR_PHASE_WORDS[entry.phase]}: ${entry.text}`),
|
|
1730
|
+
]
|
|
1731
|
+
: []),
|
|
1732
|
+
];
|
|
1733
|
+
}
|
|
1734
|
+
export function parseDirectorSwitch(word) {
|
|
1735
|
+
if (word === "on")
|
|
1736
|
+
return { on: true };
|
|
1737
|
+
if (word === "off")
|
|
1738
|
+
return { on: false };
|
|
1739
|
+
if (word === "follow")
|
|
1740
|
+
return { on: null };
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
export async function directorFlow(runId, on, json, deps) {
|
|
1744
|
+
const res = await deps.post(DIRECTOR_PATH, { runId, on });
|
|
1745
|
+
const behind = missingRouteLine(res, "exodus video director");
|
|
1746
|
+
if (behind) {
|
|
1747
|
+
return {
|
|
1748
|
+
code: 1,
|
|
1749
|
+
lines: json ? [JSON.stringify({ ok: false, status: 404, error: behind })] : [behind],
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
if (!res.ok)
|
|
1753
|
+
return errorResult(res, json);
|
|
1754
|
+
if (json)
|
|
1755
|
+
return { code: 0, lines: [JSON.stringify({ runId, on })] };
|
|
1756
|
+
const said = on === null
|
|
1757
|
+
? `The director on ad run ${runId} now follows the brand's own setting.`
|
|
1758
|
+
: `The director is ${on ? "on" : "off"} for ad run ${runId}.`;
|
|
1759
|
+
return {
|
|
1760
|
+
code: 0,
|
|
1761
|
+
lines: [
|
|
1762
|
+
said,
|
|
1763
|
+
...(on === true
|
|
1764
|
+
? ["If the ad is already waiting at the storyboard or the final watch, a review starts now."]
|
|
1765
|
+
: []),
|
|
1766
|
+
`See what it is doing: exodus video status ${runId}`,
|
|
1767
|
+
],
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1583
1770
|
export async function rejectedDraftFlow(runId, json, deps) {
|
|
1584
1771
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1585
1772
|
if (!runRes.ok)
|
|
@@ -1805,7 +1992,10 @@ export function planClipRedo(run, items, target) {
|
|
|
1805
1992
|
const { row, uploadedCut } = found;
|
|
1806
1993
|
const redoable = row.status === "failed" ||
|
|
1807
1994
|
row.staleClaim === true ||
|
|
1808
|
-
(row.status === "done" &&
|
|
1995
|
+
(row.status === "done" &&
|
|
1996
|
+
(row.flagged === true ||
|
|
1997
|
+
stop.at === "final-watch" ||
|
|
1998
|
+
(stop.at === "running" && stop.autoRedoScenes !== undefined)));
|
|
1809
1999
|
if (!redoable) {
|
|
1810
2000
|
if (row.status === "done") {
|
|
1811
2001
|
return {
|
|
@@ -2299,6 +2489,9 @@ export async function pullFlow(runId, dir, json, deps) {
|
|
|
2299
2489
|
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
2300
2490
|
...musicLines(plan.manifest),
|
|
2301
2491
|
];
|
|
2492
|
+
for (const sceneIndex of plan.manifest.pendingRedo) {
|
|
2493
|
+
lines.push("", `Scene ${sceneIndex} is being redone right now; its file will change. Run \`exodus video wait ${runId}\`, then pull again.`);
|
|
2494
|
+
}
|
|
2302
2495
|
const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
|
|
2303
2496
|
if (voiceFirst.length > 0) {
|
|
2304
2497
|
lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
|
|
@@ -2643,6 +2836,7 @@ export async function run(flags, occurrences) {
|
|
|
2643
2836
|
"voices",
|
|
2644
2837
|
"pull",
|
|
2645
2838
|
"upload",
|
|
2839
|
+
"director",
|
|
2646
2840
|
];
|
|
2647
2841
|
if (needsRunId.includes(sub) && !runId) {
|
|
2648
2842
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
@@ -2655,6 +2849,12 @@ export async function run(flags, occurrences) {
|
|
|
2655
2849
|
}
|
|
2656
2850
|
if (sub === "storyboard")
|
|
2657
2851
|
return printResult(await storyboardFlow(runId, json, defaultDeps));
|
|
2852
|
+
if (sub === "director") {
|
|
2853
|
+
const choice = parseDirectorSwitch(rest[1]);
|
|
2854
|
+
if (!choice)
|
|
2855
|
+
usage(`video director needs on, off or follow: exodus video director ${runId} on`);
|
|
2856
|
+
return printResult(await directorFlow(runId, choice.on, json, defaultDeps));
|
|
2857
|
+
}
|
|
2658
2858
|
if (sub === "approve") {
|
|
2659
2859
|
return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
|
|
2660
2860
|
}
|
|
@@ -378,6 +378,9 @@ export function formatRulesLine(provenance) {
|
|
|
378
378
|
return `Format rules: ${rigName}${version}${where}.${borrowed}`;
|
|
379
379
|
}
|
|
380
380
|
export function pauseAheadLine(ahead) {
|
|
381
|
+
if (ahead.after === "frames" && ahead.selfApproves) {
|
|
382
|
+
return "When the frames are ready, the app checks the storyboard and approves it itself if the checks pass, then goes on to the voices and clips. It only stops for your approval if a check finds a problem. The reference, cast and scene pictures are made first (a few cents each).";
|
|
383
|
+
}
|
|
381
384
|
return ahead.after === "frames"
|
|
382
385
|
? "This run will pause for your approval once the frames are ready. The reference, cast and scene pictures are made first (a few cents each). Nothing bigger is spent until you approve."
|
|
383
386
|
: "This run will pause for your approval once the storyboard is written. Nothing is spent on pictures, voices or clips until you approve.";
|
|
@@ -2088,6 +2091,7 @@ function videoParkStop(raw) {
|
|
|
2088
2091
|
async function waitForRun(runId, opts, deps) {
|
|
2089
2092
|
const seen = new Map();
|
|
2090
2093
|
let pausedNotified = false;
|
|
2094
|
+
let autoRedoNotified;
|
|
2091
2095
|
const landOnPark = opts.landOnPark;
|
|
2092
2096
|
const isLandingPark = (raw) => (landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason) ||
|
|
2093
2097
|
videoParkStop(raw) !== undefined;
|
|
@@ -2105,7 +2109,15 @@ async function waitForRun(runId, opts, deps) {
|
|
|
2105
2109
|
return;
|
|
2106
2110
|
const parked = isParkedSnapshot(raw);
|
|
2107
2111
|
const isLanding = isLandingPark(raw);
|
|
2108
|
-
|
|
2112
|
+
const autoRedo = parked ? classifyRun(asVideoRun(raw)) : undefined;
|
|
2113
|
+
if (autoRedo?.at === "running" && autoRedo.autoRedoScenes) {
|
|
2114
|
+
const line = ` ${stopLines(autoRedo, runId, "")[0]}`;
|
|
2115
|
+
if (line !== autoRedoNotified) {
|
|
2116
|
+
autoRedoNotified = line;
|
|
2117
|
+
opts.onProgressLine(line);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
else if (parked && !pausedNotified && !isLanding) {
|
|
2109
2121
|
pausedNotified = true;
|
|
2110
2122
|
const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
|
|
2111
2123
|
const pauseReason = raw["pauseReason"];
|
|
@@ -2113,7 +2125,9 @@ async function waitForRun(runId, opts, deps) {
|
|
|
2113
2125
|
opts.onProgressLine(line);
|
|
2114
2126
|
}
|
|
2115
2127
|
}
|
|
2116
|
-
const parkedNodeId =
|
|
2128
|
+
const parkedNodeId = autoRedo?.at === "running"
|
|
2129
|
+
? undefined
|
|
2130
|
+
: gateParkedNodeId(raw["status"], raw["pauseReason"], raw["pausedNodeId"]);
|
|
2117
2131
|
const nodes = Array.isArray(raw["nodes"]) ? raw["nodes"] : [];
|
|
2118
2132
|
for (const node of nodes) {
|
|
2119
2133
|
const isParked = parkedNodeId !== undefined && node.nodeId === parkedNodeId;
|