@aicopycoders/exodus 2026.9.2000-beta.21 → 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.
@@ -85,7 +85,7 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voic
85
85
  exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
86
86
  same, with the video model speaking voices you WROTE
87
87
  exodus video status <runId> where it is, per scene
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] [--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]
89
89
  start from a script, a conceit and a look.
90
90
  No music bed unless you pass --music.
91
91
  The app approves its own storyboard when its
@@ -94,7 +94,11 @@ exodus video start --script <file> --conceit <podcast|ugc|stage|street|personifi
94
94
  gate only when a check finds a problem, and says
95
95
  why. --review-storyboard always stops there.
96
96
  Each conceit has its own video model and voice;
97
- --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
98
102
  ```
99
103
 
100
104
  **From the storyboard on.**
@@ -952,8 +956,14 @@ the pieces:
952
956
  keeps about 0.15 s after its last word and about 0.1 s before its first (0.25 s
953
957
  when a different speaker answers), read from its `words.json`, so one clip
954
958
  runs into the next like one conversation. It only ever shortens a clip's
955
- silence and never cuts into a word. The ad's first lead-in and last tail, a
956
- 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.
957
967
  - **It ignores the continuous master.** It reads only `scene-NN.voice` files;
958
968
  it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
959
969
  master down whole and cuts the pictures to it keeps the read intact.
@@ -968,7 +978,10 @@ the pieces:
968
978
  ad, and the music bed underneath at low level. Output is H.264 + AAC in an MP4
969
979
  with `-movflags +faststart`.
970
980
  - Flags: `--out <file>`, `--skip 2,5`, `--no-music`. It prints what went in,
971
- 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.
972
985
  - Beside the MP4 it writes `cut.words.json` (every spoken word of the finished
973
986
  ad with its start and end) and `cut.srt` (the same words grouped into subtitle
974
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;
@@ -159,6 +160,22 @@ export interface VideoRun {
159
160
  };
160
161
  workflowId?: string;
161
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
+ }[];
162
179
  }
163
180
  export declare function asVideoRun(data: unknown): VideoRun;
164
181
  export interface NodeItem {
@@ -331,6 +348,8 @@ export interface VideoManifest {
331
348
  dashboardUrl: string;
332
349
  storyboard: string | null;
333
350
  reference: string | null;
351
+ productSheet: string | null;
352
+ setPlate: string | null;
334
353
  music: string | null;
335
354
  musicBed: MusicBedState;
336
355
  musicHeardScenes: number[];
@@ -382,6 +401,7 @@ export interface ScriptStartOptions {
382
401
  voiceMode?: VoiceMode;
383
402
  music?: boolean;
384
403
  reviewStoryboard?: true;
404
+ director?: true;
385
405
  wait: boolean;
386
406
  json: boolean;
387
407
  }
@@ -413,6 +433,12 @@ export declare function waitFlow(runId: string, opts: {
413
433
  maxPolls?: number;
414
434
  }, deps: VideoDeps): Promise<FlowResult>;
415
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>;
416
442
  export declare function rejectedDraftFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
417
443
  export declare function storyboardFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
418
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] [--review-storyboard] [--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)
@@ -147,6 +148,10 @@ Options:
147
148
  --review-storyboard Stop at the storyboard so you can approve it yourself
148
149
  (start, with --conceit). By default the app approves its
149
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
150
155
  --wait Wait until the run needs you (start): at the
151
156
  storyboard, or once every piece is made
152
157
  --json Machine-readable output
@@ -170,10 +175,12 @@ Examples:
170
175
  exodus video voices run_123 --from voices.json
171
176
  exodus video pull run_123 --out ./ad-run_123
172
177
  exodus video upload run_123 --file ./cut.mp4
178
+ exodus video director run_123 on
173
179
  `.trim();
174
180
  const SHOWS_PATH = "/api/v2/shows";
175
181
  const RUNS_PATH = "/api/v2/video/runs";
176
182
  const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
183
+ const DIRECTOR_PATH = "/api/v2/video/director";
177
184
  const RUN_PATH = "/api/v2/workflow";
178
185
  const ITEMS_PATH = "/api/v2/workflow/items";
179
186
  const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
@@ -781,13 +788,19 @@ export function planPull(run, items, opts) {
781
788
  }
782
789
  }
783
790
  let reference = null;
791
+ let productSheet = null;
792
+ let setPlate = null;
784
793
  for (const artifact of outputsOfNodeKind(run, "reference")) {
785
794
  if (artifact.type !== "image" || !artifact.imageUrl)
786
795
  continue;
787
- reference = {
788
- file: `reference.${extFor(artifact.imageUrl, "image")}`,
789
- url: artifact.imageUrl,
790
- };
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;
791
804
  }
792
805
  const keyframeByScene = new Map();
793
806
  for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
@@ -954,6 +967,8 @@ export function planPull(run, items, opts) {
954
967
  const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
955
968
  const downloads = [
956
969
  ...(reference ? [reference] : []),
970
+ ...(productSheet ? [productSheet] : []),
971
+ ...(setPlate ? [setPlate] : []),
957
972
  ...keyframeByScene.values(),
958
973
  ...[...voiceByScene.values()].map((v) => v.download),
959
974
  ...(narrationDownload ? [narrationDownload] : []),
@@ -970,6 +985,8 @@ export function planPull(run, items, opts) {
970
985
  dashboardUrl: reviewUrl(opts.dashboardUrl, run),
971
986
  storyboard,
972
987
  reference: reference?.file ?? null,
988
+ productSheet: productSheet?.file ?? null,
989
+ setPlate: setPlate?.file ?? null,
973
990
  music: music?.file ?? null,
974
991
  musicBed: musicBedState(run),
975
992
  musicHeardScenes: scenes
@@ -1002,6 +1019,10 @@ export function markPullFailure(manifest, failure) {
1002
1019
  manifest.storyboard = null;
1003
1020
  if (manifest.reference === failure.file)
1004
1021
  manifest.reference = null;
1022
+ if (manifest.productSheet === failure.file)
1023
+ manifest.productSheet = null;
1024
+ if (manifest.setPlate === failure.file)
1025
+ manifest.setPlate = null;
1005
1026
  if (manifest.music === failure.file)
1006
1027
  manifest.music = null;
1007
1028
  for (const ref of manifest.cast) {
@@ -1238,6 +1259,20 @@ export function planVideoStart(flags, occurrences) {
1238
1259
  line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
1239
1260
  };
1240
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
+ }
1241
1276
  if (showId && (videoModel || voiceMode)) {
1242
1277
  return {
1243
1278
  kind: "usage",
@@ -1302,6 +1337,8 @@ export function planVideoStart(flags, occurrences) {
1302
1337
  opts.music = musicChoice.music;
1303
1338
  if (reviewStoryboard)
1304
1339
  opts.reviewStoryboard = true;
1340
+ if (director)
1341
+ opts.director = true;
1305
1342
  return { kind: "script", opts };
1306
1343
  }
1307
1344
  function isRecord(value) {
@@ -1331,7 +1368,7 @@ function startedVideoChoice(value) {
1331
1368
  return null;
1332
1369
  return { videoModel: value.videoModel, voiceMode: value.voiceMode };
1333
1370
  }
1334
- function scriptStartedLines(runId, url, card, video) {
1371
+ function scriptStartedLines(runId, url, card, video, director) {
1335
1372
  return [
1336
1373
  `Started ad run ${runId}`,
1337
1374
  ...(card
@@ -1343,6 +1380,7 @@ function scriptStartedLines(runId, url, card, video) {
1343
1380
  ]
1344
1381
  : ["The server did not include a resolved card."]),
1345
1382
  ...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
1383
+ ...(director ? ["Director: on for this ad"] : []),
1346
1384
  `Watch it: ${url}`,
1347
1385
  ];
1348
1386
  }
@@ -1390,6 +1428,7 @@ export async function startScriptFlow(opts, deps) {
1390
1428
  ...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
1391
1429
  ...(opts.music === true ? { music: true } : {}),
1392
1430
  ...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
1431
+ ...(opts.director ? { director: true } : {}),
1393
1432
  });
1394
1433
  if (!res.ok)
1395
1434
  return errorResult(res, opts.json);
@@ -1407,7 +1446,7 @@ export async function startScriptFlow(opts, deps) {
1407
1446
  lines: opts.json
1408
1447
  ? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
1409
1448
  : [
1410
- ...scriptStartedLines(runId, url, card, video),
1449
+ ...scriptStartedLines(runId, url, card, video, opts.director === true),
1411
1450
  "",
1412
1451
  ...(opts.reviewStoryboard
1413
1452
  ? ["Wait for the storyboard here instead: exodus video start … --wait"]
@@ -1424,7 +1463,7 @@ export async function startScriptFlow(opts, deps) {
1424
1463
  return waitJsonWithCard(waited, card, video);
1425
1464
  return {
1426
1465
  code: waited.code,
1427
- lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
1466
+ lines: [...scriptStartedLines(runId, url, card, video, opts.director === true), "", ...waited.lines],
1428
1467
  };
1429
1468
  }
1430
1469
  const POLL_INTERVAL_MS = 5000;
@@ -1549,6 +1588,7 @@ export async function statusFlow(runId, json, deps) {
1549
1588
  ? { storyboardNodeId: failedStoryboard.nodeId }
1550
1589
  : {}),
1551
1590
  ...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
1591
+ ...(run.director ? { director: run.director } : {}),
1552
1592
  }),
1553
1593
  ],
1554
1594
  };
@@ -1598,6 +1638,7 @@ export async function statusFlow(runId, json, deps) {
1598
1638
  ...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
1599
1639
  ...planFailureLines(failedStoryboard, runId),
1600
1640
  ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
1641
+ ...(run.director ? ["", ...directorLines(run.director)] : []),
1601
1642
  ];
1602
1643
  if (byScene.size === 0) {
1603
1644
  lines.push("", "No scenes yet — this run hasn't made anything to look at.");
@@ -1660,6 +1701,72 @@ export async function statusFlow(runId, json, deps) {
1660
1701
  : "Final cut: not uploaded yet.");
1661
1702
  return { code: 0, lines };
1662
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
+ }
1663
1770
  export async function rejectedDraftFlow(runId, json, deps) {
1664
1771
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1665
1772
  if (!runRes.ok)
@@ -2729,6 +2836,7 @@ export async function run(flags, occurrences) {
2729
2836
  "voices",
2730
2837
  "pull",
2731
2838
  "upload",
2839
+ "director",
2732
2840
  ];
2733
2841
  if (needsRunId.includes(sub) && !runId) {
2734
2842
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
@@ -2741,6 +2849,12 @@ export async function run(flags, occurrences) {
2741
2849
  }
2742
2850
  if (sub === "storyboard")
2743
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
+ }
2744
2858
  if (sub === "approve") {
2745
2859
  return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
2746
2860
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.21",
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",