@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.
@@ -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, and the same parks follow. The full line is under
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.** Starting the run writes the storyboard and then draws the
173
- reference, cast and scene pictures, and only then pauses. Each picture is a few
174
- cents and each scene draws a few to choose from: about 25 cents on a small test,
175
- and it can approach a dollar on a longer ad. Approving releases the expensive
176
- part: the voices and the clips. So a run that has not paused yet is already
177
- spending a little, and a run still drawing pictures is healthy. `exodus workflow
178
- status` and `exodus video status` both print "This run will pause for your
179
- approval once the frames are ready" on a run that is headed for the gate. Do not
180
- cancel it because the pause has not arrived. `workflow list`, `workflow describe`, `video status`,
181
- `storyboard` and `pull` are free reads; run them as often as you like. Never
182
- start a second run to "retry" without the user asking; tell them what failed
183
- instead.
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 lead-in and last tail, a
932
- narrated scene, a still and a reaction beat are left as they are.
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
- // and its sound fades out quietly under the next line rather than stopping.
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. Mirrors scout trim.ts soundBetween.
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 && db[i] < SOUND_DB) i++;
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
- ["-v", "error", "-i", file, "-map", "0:a:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"],
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
- * and leaves the ad's first lead-in and last tail alone; a clip whose closing
426
- * line cues a reaction is not the ad's end, since the beat follows it. Trims
427
- * land on whole frames so the picture and its sound stay the same length.
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 = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
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 when it has some (older 4 s pulls), else its last
524
- * stretch replayed, so a clip exactly as long as its beat still fades out.
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 beatEnd = seg.inSec + seg.seconds;
529
- const fromSec = Math.max(0, Math.min(beatEnd, clipLen - REACTION_TAIL_SEC));
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
- const next = spine[k + 1];
652
- if (!seg.reaction || seg.audio !== "clip" || !next) return;
653
- seg.reaction.tail = reactionTail(seg, next);
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=${EDGE_FADE_SEC},` +
744
- `afade=t=out:st=${sec(seg.seconds - EDGE_FADE_SEC)}:d=${EDGE_FADE_SEC}`;
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 fades = tail
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
  }
@@ -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: {
@@ -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
- --wait Wait until the storyboard needs a yes (start)
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
- reference = {
749
- file: `reference.${extFor(artifact.imageUrl, "image")}`,
750
- url: artifact.imageUrl,
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
- "Wait for the storyboard here instead: exodus video start … --wait",
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 lastStage = null;
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
- if (stop.stage !== lastStage) {
1391
- lastStage = stop.stage;
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(`Working: ${stageWord(stop.stage)}…`);
1494
+ lines.push(progress);
1394
1495
  }
1395
1496
  continue;
1396
1497
  }
1397
- const resolved = await resolveStopAtPark(stop, runId, deps);
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" && (row.flagged === true || stop.at === "final-watch"));
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
  }
@@ -460,6 +460,7 @@ export interface WorkflowRun {
460
460
  pauseAhead?: {
461
461
  nodeId: string;
462
462
  after: "frames" | "storyboard";
463
+ selfApproves?: true;
463
464
  };
464
465
  }
465
466
  export declare function pauseAheadLine(ahead: NonNullable<WorkflowRun["pauseAhead"]>): string;
@@ -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
- if (parked && !pausedNotified && !isLanding) {
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 = gateParkedNodeId(raw["status"], raw["pauseReason"], raw["pausedNodeId"]);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.20",
3
+ "version": "2026.9.2000-beta.22",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",