@aicopycoders/exodus 2026.10.2100 → 2026.10.2300-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -268,6 +268,7 @@ Then:
268
268
  exodus video voices <runId> [--set <who>=<voiceId>] [--clear <who>] [--from <f>] [--json] who speaks, with whose voice
269
269
  exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
270
270
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "<t>"] [--json] redo one still at the pixel gate
271
+ exodus video broll <runId> [add --line <id> | move --scene <n> --line <id> | remove --scene <n>] [--note "<t>"] [--json] direct a B-roll run's shots at the storyboard
271
272
  exodus video status <runId> [--json] the park and each scene's clip/voice/picture
272
273
  exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
273
274
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "<t>"] [--json] redo one clip at the final watch
@@ -128,8 +128,10 @@ CLI words it the way the run page does. On their own brands they can:
128
128
 
129
129
  Admin-only, answered "video isn't enabled for this key" (or, for retry-clip and
130
130
  revoice, a sentence saying they are for admins): `director`,
131
- `status --rejected-draft`, `retry-clip`, `revoice`, and the start flags
131
+ `status --rejected-draft`, `retry-clip`, `revoice`, `broll`, and the start flags
132
132
  `--video-model`, `--voice`, `--voice-path` and `--director`.
133
+ `--broll` works for whoever B-roll is switched on for, beta testers included,
134
+ but nothing open to them changes the shots the app plans (see "B-roll").
133
135
  - Other failures: `npx @aicopycoders/exodus doctor` first, then follow what it prints.
134
136
  - `ffprobe` on the PATH lets `upload` read the cut's length by itself. Without
135
137
  it, `upload` falls back to parsing an MP4's own header, and for anything else
@@ -158,13 +160,18 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voic
158
160
  exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
159
161
  same, with the video model speaking voices you WROTE
160
162
  exodus video status <runId> where it is, per scene
161
- exodus video start --script <file> --conceit <kind> --style <look> [--direction "…"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--one-shot] [--director] [--stop-at-script] [--rewrite-to-fit] [--wait]
163
+ exodus video start --script <file> --conceit <kind> --style <look> [--direction "…"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--broll] [--one-shot] [--director] [--stop-at-script] [--rewrite-to-fit] [--wait]
162
164
  start from a script and the kinds the server offers.
163
165
  The file can be a plain transcript, a draft or a
164
166
  description: the server tidies it (see "The script
165
167
  file"). --stop-at-script prints the tidied or
166
168
  written script and starts nothing.
167
169
  No music bed unless you pass --music.
170
+ --broll cuts away to B-roll shots, for
171
+ accounts with B-roll switched on. At the
172
+ storyboard, `video broll` adds, moves or
173
+ removes a shot (see "Reading the storyboard
174
+ gate").
168
175
  --frame pov (conversation conceits) and --pacing
169
176
  (presenter conceits) change the shots; a conceit
170
177
  that can't follow one refuses it.
@@ -193,6 +200,11 @@ exodus video voices <runId> --set <character>=<voiceId> give a character a voi
193
200
  exodus video approve <runId> keep going
194
201
  exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
195
202
  (a beta tester leaves --node off)
203
+ exodus video broll <runId> B-roll run: the shots, and the script lines by id
204
+ exodus video broll add <runId> --line <id> [--note "…"] plan a new B-roll shot on that line
205
+ exodus video broll move <runId> --scene <n> --line <id> [--note "…"]
206
+ move B-roll scene n to another line
207
+ exodus video broll remove <runId> --scene <n> take B-roll scene n out
196
208
  exodus video status <runId> where it is, per scene
197
209
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
198
210
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"] [--voice-first]
@@ -225,7 +237,11 @@ under it:
225
237
 
226
238
  - storyboard gate: needs `approve`. When the stop block prints a `Redo one
227
239
  frame:` line with a node id, one still can be redone first with `retry-frame`;
228
- a gate parked before the stills are drawn has nothing to redraw.
240
+ a gate parked before the stills are drawn has nothing to redraw. When the run
241
+ has B-roll (`storyboard` ends with a `change the B-roll shots:` line), offer
242
+ to add a shot on a line with a note, move one, or remove one with `video
243
+ broll`. An add or a move takes about a minute and redraws the pictures, so
244
+ wait for it to finish before you approve.
229
245
  - final watch: every piece is made; needs `pull`, a cut, and `upload`. Once a
230
246
  cut is uploaded the stop block leads with `approve` instead, and `status
231
247
  --json` says which state it is in (`stop.cutAttached`, plus the same next-step
@@ -580,9 +596,65 @@ changing the workflow. Approving is what releases the voices and the clips — t
580
596
  expensive part — so do not approve a storyboard you have just told the user is
581
597
  wrong.
582
598
 
599
+ **When the run has B-roll.** The storyboard ends with `change the B-roll shots:
600
+ exodus video broll <runId>`. Run that: it lists each B-roll shot (its scene
601
+ number, the line it sits on, the words it covers and its seconds) and every
602
+ script line with its id. Tell the user which lines have a shot, then offer three
603
+ changes:
604
+
605
+ - add a shot on a line, with a note on what it should show:
606
+ `exodus video broll add <runId> --line <id> --note "what it shows"`
607
+ - move a shot to another line:
608
+ `exodus video broll move <runId> --scene <n> --line <id>`, with an optional
609
+ `--note`
610
+ - remove a shot: `exodus video broll remove <runId> --scene <n>`
611
+
612
+ An add or a move plans the shot on a worker. It takes about a minute, and then
613
+ the storyboard pictures are redrawn. A remove saves at once and also redraws the
614
+ pictures. While a shot is being planned, `video broll` says so and the server
615
+ refuses another change. Run `video broll <runId>` again until it no longer says
616
+ it is planning, then read the storyboard again before you ask about approving.
617
+ If it says the last change didn't work, pass its sentence to the user as it
618
+ came. Do not approve while a shot is still being planned.
619
+
583
620
  Do not approve on the user's behalf unless they told you to run the whole loop
584
621
  unattended.
585
622
 
623
+ ## B-roll
624
+
625
+ B-roll is a short shot the picture cuts away to while the speaker's voice keeps
626
+ going: the product, how it works inside the body, a result, a proof. Most top
627
+ ads use it. A run makes none unless it was started with `--broll`, and only an
628
+ account with B-roll switched on can pass it. The choice is fixed when the run
629
+ starts; it cannot be turned on afterwards.
630
+
631
+ **When to start with `--broll`.** Offer it whenever the script talks about
632
+ something a picture could show: the product, the mechanism, a before and after,
633
+ a number or a result. Leave it off when the user wants nothing but the person
634
+ talking. The app plans the shots and decides how many; it may plan few or none.
635
+ To direct the shots, start Guided (the default). `--one-shot` approves its own
636
+ storyboard, so nobody gets to change the B-roll.
637
+
638
+ **Reviewing and directing it.** At the storyboard gate, `video broll` lists the
639
+ shots and the lines, and adds, moves or removes one (see "Reading the
640
+ storyboard gate"). That is admin-only: a beta tester sees the shots in the
641
+ storyboard and can approve it, or stop the run on the video's page, but cannot
642
+ change them. A note on an add or a move says what the
643
+ shot should show, in one or two plain sentences ("close on the jar lid
644
+ twisting off", "the cream sinking into the skin, seen from inside"). Keep it
645
+ under 500 characters.
646
+
647
+ **The look.** A stylized ad (claymation, Pixar, anime, any animated style)
648
+ keeps its own look in every B-roll shot and goes closer or inside the body
649
+ instead. It changes look only when a note asks for a different one. A realistic, live-action ad may use any look a shot needs: a clean 3D
650
+ medical render, an animated diagram, a stock-footage look. Pass the user's
651
+ wish in the note; the app keeps or drops it by this rule.
652
+
653
+ **A shot that fails.** The app first tries a new idea for the shot, then a still
654
+ picture with a slow zoom. If both fail it leaves the shot out and the speaker
655
+ stays on screen. An admin's `pull` names those scenes and lists them under `dropped` in the
656
+ manifest. They have no clip, so cut with `--skip <those numbers>`.
657
+
586
658
  ## Giving the characters voices
587
659
 
588
660
  By default a workflow run's characters have no voice of their own. A character
@@ -834,6 +906,10 @@ pendingRedo[] scene numbers the app is redoing right
834
906
  because the file on the run is the take
835
907
  being replaced: run `exodus video wait`,
836
908
  then pull again
909
+ dropped[] B-roll scene numbers the app left out
910
+ after a new idea and a still both failed.
911
+ They have no clip on purpose: cut with
912
+ `--skip` and stay on the speaker
837
913
  narration { file, timing }, or null on a per-scene run
838
914
  cast[] one per identity still
839
915
  characterId, name from the run's cast lock; null for an
@@ -1160,7 +1236,10 @@ Read these out of `storyboard.json` before you build anything, in any tool:
1160
1236
  at the moment that script line is spoken, taking no audio and no place in the
1161
1237
  running order. To find the moment, locate the A-roll scene whose `lineIds`
1162
1238
  contain the cue line, then read the start time of the first word of that line
1163
- out of that scene's `words.json`.
1239
+ out of that scene's `words.json`. A B-roll cutaway (one with `broll`) starts
1240
+ on the first word of `broll.covers` instead and holds through its last word,
1241
+ 0.8 s to 4 s; a shot planned longer than 4 s (a talking character) holds at
1242
+ least `broll.plannedSec`.
1164
1243
  - **Reactions.** A cutaway with `cutawayType: "reaction"` is the exception. Do
1165
1244
  not lay it over the speech. Put it into the running order as its own beat
1166
1245
  right after its cue line ends, play its own sound (the audience laughing),
@@ -5,7 +5,8 @@
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
- // fading in across any quiet start and out rather than stopping dead.
8
+ // fading in across any quiet start and out rather than stopping dead. A B-roll
9
+ // cutaway lays over just the words it covers, from the first to the last.
9
10
  //
10
11
  // node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music] [--still-fill]
11
12
  //
@@ -110,6 +111,13 @@ export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
110
111
  export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
111
112
  /** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
112
113
  const EXTRA_CLEARANCE_SEC = 0.05;
114
+ /**
115
+ * A B-roll shot holds over the words it covers (#3912), but never shorter than
116
+ * the eye needs to read it nor so long it drags; a shot planned past the
117
+ * longest hold (a talking character) keeps its planned length.
118
+ */
119
+ export const BROLL_MIN_HOLD_SEC = 0.8;
120
+ export const BROLL_MAX_HOLD_SEC = 4;
113
121
 
114
122
  /** The word rule the planner sized every line with (scout models.ts wordCount). */
115
123
  export function wordCount(text) {
@@ -685,6 +693,10 @@ export function scenePlans(storyboard) {
685
693
  durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
686
694
  actionTailSec: typeof scene.actionTailSec === "number" && scene.actionTailSec > 0 ? scene.actionTailSec : 0,
687
695
  actionLeadSec: typeof scene.actionLeadSec === "number" && scene.actionLeadSec > 0 ? scene.actionLeadSec : 0,
696
+ broll:
697
+ hasText(scene.broll?.covers) && Number.isFinite(scene.broll?.plannedSec) && scene.broll.plannedSec > 0
698
+ ? { covers: scene.broll.covers, plannedSec: scene.broll.plannedSec }
699
+ : null,
688
700
  });
689
701
  }
690
702
  return plans;
@@ -709,25 +721,45 @@ export function fitNarration({ clipSeconds, spokenEndSec, airSec, actionTailSec
709
721
  return { seconds: target, holdSec: target - clipSeconds, trimmedBy: 0 };
710
722
  }
711
723
 
724
+ /** Where a segment's cue line sits among its words, or null when the segment does not speak it. */
725
+ function lineInSegment(seg, counts, cueLineId) {
726
+ const idx = seg.lineIds.indexOf(cueLineId);
727
+ if (idx === -1) return null;
728
+ let before = 0;
729
+ let total = 0;
730
+ seg.lineIds.forEach((id, i) => {
731
+ const n = counts.get(id) ?? 0;
732
+ total += n;
733
+ if (i < idx) before += n;
734
+ });
735
+ return { idx, before, words: counts.get(cueLineId) ?? 0, total };
736
+ }
737
+
738
+ /**
739
+ * When the segment's word number `i` starts (edge "s") or ends ("e"), in
740
+ * seconds into the segment: from its word times, else its share of the words.
741
+ */
742
+ function wordTimeInSegment(seg, i, total, edge) {
743
+ let at = 0;
744
+ if (seg.words && seg.words.length > 0) {
745
+ at = Math.max(0, seg.words[Math.min(Math.max(i, 0), seg.words.length - 1)]?.[edge] ?? 0);
746
+ } else if (total > 0) {
747
+ at = ((edge === "e" ? i + 1 : i) / total) * seg.seconds;
748
+ }
749
+ return Math.min(at, seg.seconds);
750
+ }
751
+
752
+ const timesFrom = (seg) => (seg.words && seg.words.length > 0 ? "words" : "proportion");
753
+
712
754
  export function cueOffsetOnSpine(spine, counts, cueLineId) {
713
755
  for (const seg of spine) {
714
- const idx = seg.lineIds.indexOf(cueLineId);
715
- if (idx === -1) continue;
716
- let precedingWords = 0;
717
- for (let i = 0; i < idx; i++) precedingWords += counts.get(seg.lineIds[i]) ?? 0;
718
- let totalWords = 0;
719
- for (const id of seg.lineIds) totalWords += counts.get(id) ?? 0;
720
-
721
- let within = 0;
722
- let method = "proportion";
723
- if (seg.words && seg.words.length > 0) {
724
- within = Math.max(0, seg.words[Math.min(precedingWords, seg.words.length - 1)]?.s ?? 0);
725
- method = "words";
726
- } else if (totalWords > 0) {
727
- within = (precedingWords / totalWords) * seg.seconds;
728
- }
729
- within = Math.min(within, seg.seconds);
730
- return { startSec: seg.startSec + within, method, spineSceneIndex: seg.sceneIndex };
756
+ const line = lineInSegment(seg, counts, cueLineId);
757
+ if (!line) continue;
758
+ return {
759
+ startSec: seg.startSec + wordTimeInSegment(seg, line.before, line.total, "s"),
760
+ method: timesFrom(seg),
761
+ spineSceneIndex: seg.sceneIndex,
762
+ };
731
763
  }
732
764
  return null;
733
765
  }
@@ -738,21 +770,83 @@ export function cueOffsetOnSpine(spine, counts, cueLineId) {
738
770
  * where the segment does.
739
771
  */
740
772
  export function cueEndInSegment(seg, counts, cueLineId) {
773
+ const line = lineInSegment(seg, counts, cueLineId);
774
+ if (!line) return null;
775
+ if (line.idx === seg.lineIds.length - 1) return seg.seconds;
776
+ const through = line.before + line.words;
777
+ if (seg.words && seg.words.length > 0) return wordTimeInSegment(seg, through - 1, line.total, "e");
778
+ return line.total > 0 ? (through / line.total) * seg.seconds : seg.seconds;
779
+ }
780
+
781
+ /** The sub-words of a text the way scout contract.ts brollCoversLine splits them. */
782
+ const coverParts = (text) => String(text ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
783
+
784
+ /**
785
+ * The first and last of `words` that hold the covered words, or null. Matched
786
+ * on sub-words as the contract does, so "proven answer" is found in
787
+ * "clinically-proven answer" and starts on "clinically-proven".
788
+ */
789
+ function coveredRun(words, covers) {
790
+ const parts = words.flatMap((word, i) => coverParts(word).map((part) => ({ part, i })));
791
+ const want = coverParts(covers);
792
+ if (want.length === 0) return null;
793
+ for (let k = 0; k + want.length <= parts.length; k++) {
794
+ if (want.every((w, j) => parts[k + j].part === w)) {
795
+ return { first: parts[k].i, last: parts[k + want.length - 1].i };
796
+ }
797
+ }
798
+ return null;
799
+ }
800
+
801
+ /**
802
+ * When the covered words are heard, in seconds into the segment, read by
803
+ * aligning the script to the transcript: "$29" is heard as three words, so the
804
+ * script's word count points at the wrong one (#3912). Null when the segment
805
+ * has no word times or the script does not align.
806
+ */
807
+ function heardCovers(seg, texts, cueLineId, covers) {
808
+ if (!seg.words || seg.words.length === 0) return null;
809
+ const lines = seg.lineIds.map((id) => scriptWords(texts.get(id)));
741
810
  const idx = seg.lineIds.indexOf(cueLineId);
742
- if (idx === -1) return null;
743
- if (idx === seg.lineIds.length - 1) return seg.seconds;
744
- let through = 0;
745
- let totalWords = 0;
746
- seg.lineIds.forEach((id, i) => {
747
- const n = counts.get(id) ?? 0;
748
- totalWords += n;
749
- if (i <= idx) through += n;
750
- });
751
- if (seg.words && seg.words.length > 0) {
752
- const last = seg.words[Math.min(Math.max(through - 1, 0), seg.words.length - 1)];
753
- return Math.min(Math.max(0, last?.e ?? 0), seg.seconds);
811
+ const run = coveredRun(lines[idx], covers);
812
+ const aligned = run ? alignScript(seg.words, lines.flat()) : null;
813
+ if (!aligned) return null;
814
+ const offset = lines.slice(0, idx).reduce((n, words) => n + words.length, 0);
815
+ const { expected, heard, heardTokenOf, heardWordOf } = aligned;
816
+ const first = expected.lastSourceIndex.findIndex((i) => i >= offset + run.first);
817
+ const last = expected.sourceIndex.findLastIndex((i) => i <= offset + run.last);
818
+ if (first === -1 || last < first) return null;
819
+ const at = (i, edge) => Math.min(Math.max(0, seg.words[i]?.[edge] ?? 0), seg.seconds);
820
+ return { startSec: at(heardWordOf[first], "s"), endSec: at(heard.lastSourceIndex[heardTokenOf[last]], "e") };
821
+ }
822
+
823
+ /**
824
+ * #3912: a B-roll shot's place in its segment. It starts on the first word it
825
+ * covers and holds through the last, between BROLL_MIN_HOLD_SEC and
826
+ * BROLL_MAX_HOLD_SEC, or for its planned length when that runs longer than the
827
+ * cap. Covered words the line does not say leave it at the line's start for
828
+ * its planned length. `texts` maps each line id to its script text. Null when
829
+ * the segment does not speak the cue line.
830
+ */
831
+ export function brollWindow(seg, counts, cueLineId, texts, broll) {
832
+ const line = lineInSegment(seg, counts, cueLineId);
833
+ if (!line) return null;
834
+ const method = timesFrom(seg);
835
+ const run = coveredRun((texts.get(cueLineId) ?? "").trim().split(/\s+/), broll.covers);
836
+ if (!run) {
837
+ return {
838
+ startSec: wordTimeInSegment(seg, line.before, line.total, "s"),
839
+ holdSec: broll.plannedSec, method, found: false,
840
+ };
754
841
  }
755
- return totalWords > 0 ? (through / totalWords) * seg.seconds : seg.seconds;
842
+ const heard = heardCovers(seg, texts, cueLineId, broll.covers);
843
+ const startSec = heard?.startSec ?? wordTimeInSegment(seg, line.before + run.first, line.total, "s");
844
+ const endSec = heard?.endSec ?? wordTimeInSegment(seg, line.before + run.last, line.total, "e");
845
+ const span = Math.max(0, endSec - startSec);
846
+ const holdSec = broll.plannedSec > BROLL_MAX_HOLD_SEC
847
+ ? Math.max(span, broll.plannedSec)
848
+ : Math.min(Math.max(span, BROLL_MIN_HOLD_SEC), BROLL_MAX_HOLD_SEC);
849
+ return { startSec, holdSec, method, found: true };
756
850
  }
757
851
 
758
852
  /**
@@ -976,7 +1070,9 @@ export function buildTimeline({
976
1070
  const nothing = `nothing to cut with (clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""})`;
977
1071
 
978
1072
  if (plan.cutaway) {
979
- const cue = { cueLineId: plan.cueLineId, reaction: plan.cutawayType === "reaction" };
1073
+ const cue = {
1074
+ cueLineId: plan.cueLineId, reaction: plan.cutawayType === "reaction", broll: plan.broll ?? null,
1075
+ };
980
1076
  if (exists(scene.clip)) {
981
1077
  const clip = probe(scene.clip);
982
1078
  waiting.push({
@@ -1124,13 +1220,25 @@ export function buildTimeline({
1124
1220
  if (seg.reaction?.tail) seg.reaction.tail.atSec = spine[k + 1].startSec;
1125
1221
  });
1126
1222
 
1223
+ const lineTexts = new Map([...scriptLines].map(([id, line]) => [id, line?.text]));
1127
1224
  for (const c of waiting.filter((w) => !w.reaction)) {
1128
1225
  const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
1129
1226
  if (!cue) {
1130
1227
  dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
1131
1228
  continue;
1132
1229
  }
1133
- cutaways.push({ ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex });
1230
+ const placed = { ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex };
1231
+ const seg = c.broll ? spine.find((s) => s.lineIds.includes(c.cueLineId)) : null;
1232
+ const win = seg
1233
+ ? brollWindow(seg, counts, c.cueLineId, lineTexts, c.broll)
1234
+ : null;
1235
+ if (win) {
1236
+ placed.startSec = seg.startSec + win.startSec;
1237
+ placed.method = win.method;
1238
+ placed.seconds = c.source === "clip" ? Math.min(win.holdSec, c.seconds) : win.holdSec;
1239
+ placed.coversFound = win.found;
1240
+ }
1241
+ cutaways.push(placed);
1134
1242
  }
1135
1243
  cutaways.sort((a, b) => a.startSec - b.startSec);
1136
1244
  const beats = spine.filter((seg) => seg.reaction);
@@ -1429,11 +1537,16 @@ export function inTheCut(timeline) {
1429
1537
  ? "word times"
1430
1538
  : `proportion: scene ${c.spineSceneIndex} has no word times`;
1431
1539
  const cut = c.cutShortBy ? `, cut short where ${c.cutShortBy}` : "";
1540
+ const covers = !c.broll
1541
+ ? ""
1542
+ : c.coversFound
1543
+ ? `, B-roll covering "${c.broll.covers}"`
1544
+ : `, B-roll: "${c.broll.covers}" is not in the line, so it starts with the line for its planned ${c.broll.plannedSec}s`;
1432
1545
  rows.push({
1433
1546
  n: c.sceneIndex,
1434
1547
  line:
1435
1548
  `cutaway ${c.sceneIndex}: ${label} at ${c.startSec.toFixed(2)}s for ${c.seconds.toFixed(1)}s, ` +
1436
- `cued to ${c.cueLineId} in scene ${c.spineSceneIndex} (${how})${cut}`,
1549
+ `cued to ${c.cueLineId} in scene ${c.spineSceneIndex} (${how})${covers}${cut}`,
1437
1550
  });
1438
1551
  }
1439
1552
  return rows.sort((a, b) => a.n - b.n).map((r) => r.line);
@@ -400,6 +400,7 @@ export interface VideoManifest {
400
400
  musicBed: MusicBedState;
401
401
  musicHeardScenes: number[];
402
402
  pendingRedo: number[];
403
+ dropped: number[];
403
404
  cast: ManifestCastRef[];
404
405
  narration: {
405
406
  file: string;
@@ -455,6 +456,7 @@ export interface ScriptStartOptions {
455
456
  music?: boolean;
456
457
  reviewStoryboard?: true;
457
458
  director?: true;
459
+ broll?: true;
458
460
  shots?: RunShots;
459
461
  stopAtScript?: true;
460
462
  rewriteToFit?: true;
@@ -505,6 +507,34 @@ export declare function approveFlow(runId: string, opts: {
505
507
  }, deps: VideoDeps): Promise<FlowResult>;
506
508
  export declare function flagFlow(runId: string, note: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
507
509
  export declare function retryFrameFlow(runId: string, nodeId: string | undefined, sceneIndex: number, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
510
+ export declare const BROLL_EDIT_STALE_MS: number;
511
+ export type BrollEditAsk = {
512
+ op: "add";
513
+ lineId: string;
514
+ note?: string;
515
+ } | {
516
+ op: "move";
517
+ sceneIndex: number;
518
+ lineId: string;
519
+ note?: string;
520
+ } | {
521
+ op: "remove";
522
+ sceneIndex: number;
523
+ };
524
+ export type BrollCommand = {
525
+ kind: "list";
526
+ runId: string;
527
+ } | {
528
+ kind: "edit";
529
+ runId: string;
530
+ edit: BrollEditAsk;
531
+ } | {
532
+ kind: "usage";
533
+ line: string;
534
+ };
535
+ export declare function planBrollCommand(rest: readonly string[], flags: Record<string, string | boolean>): BrollCommand;
536
+ export declare function brollFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
537
+ export declare function brollEditFlow(runId: string, edit: BrollEditAsk, json: boolean, deps: VideoDeps): Promise<FlowResult>;
508
538
  export type ClipRedoPlan = {
509
539
  ok: true;
510
540
  nodeId: string;
@@ -74,6 +74,11 @@ The whole loop, in order:
74
74
  here only when its checks find a problem.
75
75
  exodus video retry-frame <runId> --node <nodeId> --scene <n>
76
76
  Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
77
+ exodus video broll <runId>
78
+ On a run started with --broll: the B-roll shots and the script lines.
79
+ Add a shot on a line, move one, or remove one while the storyboard
80
+ waits. An add or a move takes about a minute and redraws the pictures,
81
+ so wait for it before you approve.
77
82
 
78
83
  7. exodus video status <runId>
79
84
  Where the run is and how each scene's clip turned out. If the storyboard
@@ -115,13 +120,17 @@ Usage:
115
120
  exodus video storyboard <runId> [--json]
116
121
  exodus video approve <runId> [--approve-stale-cut] [--json]
117
122
  exodus video retry-frame <runId> [--node <nodeId>] --scene <n> [--note "..."] [--json]
123
+ exodus video broll <runId> [--json]
124
+ exodus video broll add <runId> --line <id> [--note "..."] [--json]
125
+ exodus video broll move <runId> --scene <n> --line <id> [--note "..."] [--json]
126
+ exodus video broll remove <runId> --scene <n> [--json]
118
127
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
119
128
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
120
129
  exodus video voices <runId> [--set <character>=<voice>] [--clear <character>] [--from <file.json>] [--json]
121
130
  exodus video voices [<runId>] --catalog [--json]
122
131
  exodus video pull <runId> --out <dir> [--json]
123
132
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
124
- exodus video start --script <file> --conceit <kind> --style <look> [--direction "<note>"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--one-shot] [--director] [--wait] [--json]
133
+ exodus video start --script <file> --conceit <kind> --style <look> [--direction "<note>"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--broll] [--one-shot] [--director] [--wait] [--json]
125
134
  exodus video director <runId> <on|off|follow> [--json]
126
135
 
127
136
  Options:
@@ -129,13 +138,17 @@ Options:
129
138
  --file <cut.mp4> Your finished cut (upload). MP4, MOV or WebM, up to 200MB
130
139
  --duration <sec> How long your cut is, in seconds. Only needed when the
131
140
  length can't be read off the file itself
132
- --note "<text>" How to steer one redo (retry-frame, retry-clip)
141
+ --note "<text>" How to steer one redo (retry-frame, retry-clip), or
142
+ what a B-roll shot should show (broll add, broll move)
143
+ --line <id> The script line a B-roll shot goes on (broll add,
144
+ broll move). exodus video broll <runId> lists the ids
133
145
  --node <nodeId> Which scene-frames node holds the still (retry-frame;
134
146
  admins pass it, beta testers leave it off).
135
147
  Which video step holds the clip, when a scene has one on
136
148
  more than one step (retry-clip, revoice)
137
149
  --scene <n> Which scene to redo. One scene only, a whole number
138
- (retry-frame, retry-clip, revoice)
150
+ (retry-frame, retry-clip, revoice). Which B-roll shot
151
+ to move or remove (broll move, broll remove)
139
152
  --all Every finished clip that kept the video model's voice
140
153
  (revoice)
141
154
  --voice-first Record the scene's line in the character's own voice,
@@ -194,6 +207,8 @@ Options:
194
207
  --music Put a music bed under the ad (start). An ad has no music
195
208
  unless you ask for it here
196
209
  --no-music Leave the music bed off (start), which it already is
210
+ --broll Cut away to B-roll shots in the ad (start, with
211
+ --conceit). Only for accounts with B-roll switched on
197
212
  --one-shot Make the whole video with no stop (start, with
198
213
  --conceit). The app approves its own storyboard when
199
214
  its checks pass and goes straight on to the clips,
@@ -219,7 +234,8 @@ status, storyboard, approve (the storyboard), voices, retry-frame --scene <n> (r
219
234
  picture while the storyboard waits), pull (the finished video, each scene's
220
235
  clip and voice, and the music) and upload. These stay admin-only:
221
236
  start --video-model / --voice / --voice-path / --director, director,
222
- status --rejected-draft, retry-clip and revoice.
237
+ status --rejected-draft, retry-clip, revoice and broll. start --broll works
238
+ for whoever B-roll is switched on for.
223
239
  If a command answers "video isn't enabled for this key", the run id is wrong,
224
240
  the command is admin-only, or video isn't switched on for your dashboard user.
225
241
 
@@ -230,6 +246,8 @@ Examples:
230
246
  exodus video storyboard run_123
231
247
  exodus video approve run_123
232
248
  exodus video retry-frame run_123 --node frames-1 --scene 2
249
+ exodus video broll run_123
250
+ exodus video broll add run_123 --line L4 --note "close on the jar lid"
233
251
  exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
234
252
  exodus video retry-clip run_123 --scene 3 --voice-first
235
253
  exodus video revoice run_123 --all
@@ -253,6 +271,7 @@ const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep reje
253
271
  "with the next server update.";
254
272
  const STORYBOARD_PATH = "/api/v2/video/storyboard";
255
273
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
274
+ const BROLL_PATH = "/api/v2/video/storyboard/broll";
256
275
  const APPROVE_PATH = "/api/v2/workflow/approve";
257
276
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
258
277
  const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
@@ -370,7 +389,7 @@ export const defaultDeps = {
370
389
  };
371
390
  export const VIDEO_NOT_FOUND_MESSAGE = "video isn't enabled for this key\n" +
372
391
  "One of three things: that run id doesn't exist on this brand, this command is for admins only " +
373
- "(director, status --rejected-draft, retry-clip, revoice), or video isn't " +
392
+ "(director, status --rejected-draft, retry-clip, revoice, broll), or video isn't " +
374
393
  "switched on for your dashboard user on this brand. Check the id first, then ask Brad.";
375
394
  export function videoApiError(res) {
376
395
  const behind = missingRouteLine(res, "exodus video");
@@ -1137,6 +1156,10 @@ export function planPull(run, items, opts) {
1137
1156
  .filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
1138
1157
  .map((scene) => scene.sceneIndex),
1139
1158
  pendingRedo,
1159
+ dropped: [...clipItemByScene.values()]
1160
+ .filter((item) => item.status === "rejected")
1161
+ .map((item) => item.sceneIndex)
1162
+ .sort((a, b) => a - b),
1140
1163
  cast,
1141
1164
  narration: narrationDownload
1142
1165
  ? { file: narrationDownload.file, timing: "narration.json" }
@@ -1450,6 +1473,14 @@ export function planVideoStart(flags, occurrences) {
1450
1473
  };
1451
1474
  }
1452
1475
  const director = directorFlags.length > 0;
1476
+ const brollFlags = occurrences.filter((o) => o.flag === "broll");
1477
+ if (brollFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1478
+ return {
1479
+ kind: "usage",
1480
+ line: "--broll takes no value. Leave it off to start without B-roll.",
1481
+ };
1482
+ }
1483
+ const broll = brollFlags.length > 0;
1453
1484
  const stopFlags = occurrences.filter((o) => o.flag === "stop-at-script");
1454
1485
  if (stopFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1455
1486
  return {
@@ -1484,6 +1515,12 @@ export function planVideoStart(flags, occurrences) {
1484
1515
  line: "--director works with --conceit, not --show.",
1485
1516
  };
1486
1517
  }
1518
+ if (showId && broll) {
1519
+ return {
1520
+ kind: "usage",
1521
+ line: "--broll works with --conceit, not --show.",
1522
+ };
1523
+ }
1487
1524
  if (showId && (frameFlag.value || pacingFlag.value)) {
1488
1525
  return {
1489
1526
  kind: "usage",
@@ -1550,6 +1587,8 @@ export function planVideoStart(flags, occurrences) {
1550
1587
  opts.reviewStoryboard = true;
1551
1588
  if (director)
1552
1589
  opts.director = true;
1590
+ if (broll)
1591
+ opts.broll = true;
1553
1592
  if (stopAtScript)
1554
1593
  opts.stopAtScript = true;
1555
1594
  if (rewriteToFit)
@@ -1841,6 +1880,7 @@ export async function startScriptFlow(opts, deps) {
1841
1880
  ...(opts.music === true ? { music: true } : {}),
1842
1881
  ...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
1843
1882
  ...(opts.director ? { director: true } : {}),
1883
+ ...(opts.broll ? { broll: true } : {}),
1844
1884
  ...(opts.shots ? { shots: opts.shots } : {}),
1845
1885
  ...(opts.stopAtScript ? { stopAtScript: true } : {}),
1846
1886
  ...(opts.rewriteToFit ? { rewriteToFit: true } : {}),
@@ -2019,6 +2059,7 @@ const ITEM_STATUS_WORD = {
2019
2059
  running: "working",
2020
2060
  idle: "waiting",
2021
2061
  skipped: "skipped",
2062
+ rejected: "left out",
2022
2063
  };
2023
2064
  function itemWord(item) {
2024
2065
  if (!item)
@@ -2373,6 +2414,9 @@ export async function storyboardFlow(runId, json, deps) {
2373
2414
  if (cards.framesNodeId) {
2374
2415
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
2375
2416
  }
2417
+ if (cards.broll === true) {
2418
+ lines.push(`change the B-roll shots: exodus video broll ${runId}`);
2419
+ }
2376
2420
  return { code: 0, lines };
2377
2421
  }
2378
2422
  function sceneCardLines(scene) {
@@ -2506,6 +2550,152 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
2506
2550
  ],
2507
2551
  };
2508
2552
  }
2553
+ export const BROLL_EDIT_STALE_MS = 10 * 60 * 1000;
2554
+ const BROLL_OPS = ["add", "move", "remove"];
2555
+ export function planBrollCommand(rest, flags) {
2556
+ const op = BROLL_OPS.find((o) => o === rest[0]);
2557
+ if (!op) {
2558
+ if (!rest[0])
2559
+ return { kind: "usage", line: "video broll needs a run id: exodus video broll <runId>" };
2560
+ return { kind: "list", runId: rest[0] };
2561
+ }
2562
+ const runId = rest[1];
2563
+ if (!runId) {
2564
+ return { kind: "usage", line: `video broll ${op} needs a run id: exodus video broll ${op} <runId>` };
2565
+ }
2566
+ const lineId = nonBlank(flagString(flags, "line"))?.trim();
2567
+ const note = nonBlank(flagString(flags, "note"))?.trim();
2568
+ const sceneRaw = flagString(flags, "scene");
2569
+ const sceneIndex = sceneRaw === undefined ? undefined : Number(sceneRaw);
2570
+ if (sceneIndex !== undefined && !(Number.isInteger(sceneIndex) && sceneIndex >= 1)) {
2571
+ return { kind: "usage", line: `video broll ${op} --scene must be a scene number, not "${sceneRaw}".` };
2572
+ }
2573
+ const withNote = note ? { note } : {};
2574
+ if (op === "add") {
2575
+ if (!lineId) {
2576
+ return { kind: "usage", line: "video broll add needs --line <id>, the script line the shot goes on." };
2577
+ }
2578
+ return { kind: "edit", runId, edit: { op, lineId, ...withNote } };
2579
+ }
2580
+ if (sceneIndex === undefined) {
2581
+ return {
2582
+ kind: "usage",
2583
+ line: `video broll ${op} needs --scene <n>, the B-roll shot to ${op}.`,
2584
+ };
2585
+ }
2586
+ if (op === "remove") {
2587
+ if (note)
2588
+ return { kind: "usage", line: "video broll remove takes no --note." };
2589
+ return { kind: "edit", runId, edit: { op, sceneIndex } };
2590
+ }
2591
+ if (!lineId) {
2592
+ return { kind: "usage", line: "video broll move needs --line <id>, the script line to move the shot to." };
2593
+ }
2594
+ return { kind: "edit", runId, edit: { op, sceneIndex, lineId, ...withNote } };
2595
+ }
2596
+ function seconds(value) {
2597
+ return `${Number(value.toFixed(1))}s`;
2598
+ }
2599
+ function brollEditLines(edit, now) {
2600
+ if (!edit || edit.landed)
2601
+ return [];
2602
+ const asked = edit.op === "move"
2603
+ ? `moving B-roll scene ${edit.sceneIndex} to line ${edit.lineId}`
2604
+ : `a new B-roll shot on line ${edit.lineId}`;
2605
+ if (edit.error !== undefined) {
2606
+ return [`The last B-roll change (${asked}) didn't work: ${edit.error}`];
2607
+ }
2608
+ if (now - edit.requestedAt >= BROLL_EDIT_STALE_MS) {
2609
+ return [`The last B-roll change (${asked}) never finished. You can ask for it again.`];
2610
+ }
2611
+ return [
2612
+ `Planning now: ${asked}. It takes about a minute, then the storyboard pictures are redrawn.`,
2613
+ "Wait for it to finish before you change another shot or approve the storyboard.",
2614
+ ];
2615
+ }
2616
+ export async function brollFlow(runId, json, deps) {
2617
+ const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
2618
+ if (!res.ok)
2619
+ return errorResult(res, json);
2620
+ if (customerAnswer(res.data)) {
2621
+ return adminOnlyResult("Changing the B-roll from the CLI isn't open to this key.", json);
2622
+ }
2623
+ const gate = res.data;
2624
+ if (gate.broll !== true) {
2625
+ if (json)
2626
+ return { code: 0, lines: [JSON.stringify({ runId, broll: false })] };
2627
+ return { code: 0, lines: ["This video was made without B-roll, so there are no B-roll shots to change."] };
2628
+ }
2629
+ const shots = gate.brollShots ?? [];
2630
+ const scriptLines = gate.scriptLines ?? [];
2631
+ const edit = gate.brollEdit ?? null;
2632
+ const now = deps.now();
2633
+ const planning = !!edit && edit.error === undefined && !edit.landed && now - edit.requestedAt < BROLL_EDIT_STALE_MS;
2634
+ if (json) {
2635
+ return {
2636
+ code: 0,
2637
+ lines: [JSON.stringify({ runId, broll: true, planning, brollEdit: edit, brollShots: shots, scriptLines })],
2638
+ };
2639
+ }
2640
+ if (scriptLines.length === 0) {
2641
+ return {
2642
+ code: 0,
2643
+ lines: [
2644
+ "The storyboard isn't finished yet, so there are no B-roll shots to change yet.",
2645
+ `Check where it is: exodus video status ${runId}`,
2646
+ ],
2647
+ };
2648
+ }
2649
+ const lines = brollEditLines(edit, now);
2650
+ if (lines.length > 0)
2651
+ lines.push("");
2652
+ lines.push("B-roll shots");
2653
+ if (shots.length === 0)
2654
+ lines.push(" None yet.");
2655
+ for (const shot of shots) {
2656
+ lines.push(` Scene ${shot.sceneIndex}, on line ${shot.lineId}, ${seconds(shot.plannedSec)}: "${shot.covers}"`);
2657
+ }
2658
+ lines.push("", "Script lines (pick one by its id)");
2659
+ for (const line of scriptLines) {
2660
+ lines.push(` ${line.lineId} ${line.speakerId ?? "narrator"}: ${line.text}`);
2661
+ }
2662
+ lines.push("");
2663
+ if (planning) {
2664
+ lines.push(`Check again: exodus video broll ${runId}`);
2665
+ }
2666
+ else {
2667
+ lines.push(`Add a shot: exodus video broll add ${runId} --line <id> --note "what it shows"`, `Move a shot: exodus video broll move ${runId} --scene <n> --line <id>`, `Remove a shot: exodus video broll remove ${runId} --scene <n>`);
2668
+ }
2669
+ return { code: 0, lines };
2670
+ }
2671
+ export async function brollEditFlow(runId, edit, json, deps) {
2672
+ const res = await deps.post(BROLL_PATH, { runId, ...edit });
2673
+ if (!res.ok)
2674
+ return errorResult(res, json);
2675
+ if (json)
2676
+ return { code: 0, lines: [JSON.stringify({ runId, ...res.data })] };
2677
+ if (edit.op === "remove") {
2678
+ return {
2679
+ code: 0,
2680
+ lines: [
2681
+ `B-roll scene ${edit.sceneIndex} is gone. The storyboard pictures will be redrawn.`,
2682
+ `See them: exodus video storyboard ${runId}`,
2683
+ ],
2684
+ };
2685
+ }
2686
+ return {
2687
+ code: 0,
2688
+ lines: [
2689
+ edit.op === "add"
2690
+ ? `Planning a B-roll shot on line ${edit.lineId}.`
2691
+ : `Moving B-roll scene ${edit.sceneIndex} to line ${edit.lineId}.`,
2692
+ "It takes about a minute. Then the storyboard pictures are redrawn.",
2693
+ "Wait for it to finish before you approve the storyboard.",
2694
+ `Check on it: exodus video broll ${runId}`,
2695
+ `See the pictures: exodus video storyboard ${runId}`,
2696
+ ],
2697
+ };
2698
+ }
2509
2699
  const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
2510
2700
  "exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
2511
2701
  "or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
@@ -3194,6 +3384,9 @@ export async function pullFlow(runId, dir, json, deps) {
3194
3384
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
3195
3385
  ...musicLines(plan.manifest),
3196
3386
  ];
3387
+ if (plan.manifest.dropped.length > 0) {
3388
+ lines.push("", `B-roll left out on purpose (no clip; the edit stays on the speaker): scene${plan.manifest.dropped.length === 1 ? "" : "s"} ${plan.manifest.dropped.join(", ")}. Cut with --skip ${plan.manifest.dropped.join(",")}.`);
3389
+ }
3197
3390
  for (const sceneIndex of plan.manifest.pendingRedo) {
3198
3391
  lines.push("", `Scene ${sceneIndex} is being redone right now; its file will change. Run \`exodus video wait ${runId}\`, then pull again.`);
3199
3392
  }
@@ -3541,6 +3734,7 @@ const VALUE_FLAGS = new Set([
3541
3734
  "duration",
3542
3735
  "node",
3543
3736
  "scene",
3737
+ "line",
3544
3738
  "set",
3545
3739
  "clear",
3546
3740
  "from",
@@ -3599,6 +3793,14 @@ export async function run(flags, occurrences) {
3599
3793
  usage(started.lines[0] ?? "");
3600
3794
  return printResult(started);
3601
3795
  }
3796
+ if (sub === "broll") {
3797
+ const plan = planBrollCommand(rest, flags);
3798
+ if (plan.kind === "usage")
3799
+ usage(plan.line);
3800
+ if (plan.kind === "list")
3801
+ return printResult(await brollFlow(plan.runId, json, defaultDeps));
3802
+ return printResult(await brollEditFlow(plan.runId, plan.edit, json, defaultDeps));
3803
+ }
3602
3804
  const runId = rest[0];
3603
3805
  const needsRunId = [
3604
3806
  "status",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.10.2100",
3
+ "version": "2026.10.2300-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",