@aicopycoders/exodus 2026.10.2300-beta.1 → 2026.10.2400-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.
@@ -1237,9 +1237,12 @@ Read these out of `storyboard.json` before you build anything, in any tool:
1237
1237
  running order. To find the moment, locate the A-roll scene whose `lineIds`
1238
1238
  contain the cue line, then read the start time of the first word of that line
1239
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,
1240
+ 0.5 s before the first word of `broll.covers` instead (even if that reaches
1241
+ back into the scene before, but never into a reaction or before 0 s), and
1242
+ holds through its last word or for `broll.plannedSec`, whichever is longer,
1241
1243
  0.8 s to 4 s; a shot planned longer than 4 s (a talking character) holds at
1242
- least `broll.plannedSec`.
1244
+ least `broll.plannedSec`. The next cutaway, a reaction or the ad's end cuts
1245
+ it short.
1243
1246
  - **Reactions.** A cutaway with `cutawayType: "reaction"` is the exception. Do
1244
1247
  not lay it over the speech. Put it into the running order as its own beat
1245
1248
  right after its cue line ends, play its own sound (the audience laughing),
@@ -112,9 +112,15 @@ export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
112
112
  /** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
113
113
  const EXTRA_CLEARANCE_SEC = 0.05;
114
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.
115
+ * A B-roll shot arrives this long before the first word it covers, the lead the
116
+ * top ads use and the planner teaches (#3936).
117
+ */
118
+ export const BROLL_LEAD_SEC = 0.5;
119
+ /**
120
+ * A B-roll shot holds over the words it covers (#3912) or for its planned
121
+ * length, whichever runs longer (#3936), but never shorter than the eye needs
122
+ * to read it nor so long it drags; a shot planned past the longest hold (a
123
+ * talking character) keeps its planned length.
118
124
  */
119
125
  export const BROLL_MIN_HOLD_SEC = 0.8;
120
126
  export const BROLL_MAX_HOLD_SEC = 4;
@@ -821,12 +827,14 @@ function heardCovers(seg, texts, cueLineId, covers) {
821
827
  }
822
828
 
823
829
  /**
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
+ * #3912: a B-roll shot's place in its segment. It starts BROLL_LEAD_SEC before
831
+ * the first word it covers, which can fall before the segment's own start
832
+ * (#3936), and holds through the last word or for its planned length,
833
+ * whichever is longer, between BROLL_MIN_HOLD_SEC and BROLL_MAX_HOLD_SEC, or
834
+ * for its planned length when that runs longer than the cap. Covered words the
835
+ * line does not say leave it at the line's start for its planned length.
836
+ * `texts` maps each line id to its script text. Null when the segment does not
837
+ * speak the cue line.
830
838
  */
831
839
  export function brollWindow(seg, counts, cueLineId, texts, broll) {
832
840
  const line = lineInSegment(seg, counts, cueLineId);
@@ -840,12 +848,13 @@ export function brollWindow(seg, counts, cueLineId, texts, broll) {
840
848
  };
841
849
  }
842
850
  const heard = heardCovers(seg, texts, cueLineId, broll.covers);
843
- const startSec = heard?.startSec ?? wordTimeInSegment(seg, line.before + run.first, line.total, "s");
851
+ const wordSec = heard?.startSec ?? wordTimeInSegment(seg, line.before + run.first, line.total, "s");
844
852
  const endSec = heard?.endSec ?? wordTimeInSegment(seg, line.before + run.last, line.total, "e");
853
+ const startSec = wordSec - BROLL_LEAD_SEC;
845
854
  const span = Math.max(0, endSec - startSec);
846
855
  const holdSec = broll.plannedSec > BROLL_MAX_HOLD_SEC
847
856
  ? Math.max(span, broll.plannedSec)
848
- : Math.min(Math.max(span, BROLL_MIN_HOLD_SEC), BROLL_MAX_HOLD_SEC);
857
+ : Math.min(Math.max(span, broll.plannedSec, BROLL_MIN_HOLD_SEC), BROLL_MAX_HOLD_SEC);
849
858
  return { startSec, holdSec, method, found: true };
850
859
  }
851
860
 
@@ -1221,6 +1230,7 @@ export function buildTimeline({
1221
1230
  });
1222
1231
 
1223
1232
  const lineTexts = new Map([...scriptLines].map(([id, line]) => [id, line?.text]));
1233
+ const beats = spine.filter((seg) => seg.reaction);
1224
1234
  for (const c of waiting.filter((w) => !w.reaction)) {
1225
1235
  const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
1226
1236
  if (!cue) {
@@ -1233,7 +1243,9 @@ export function buildTimeline({
1233
1243
  ? brollWindow(seg, counts, c.cueLineId, lineTexts, c.broll)
1234
1244
  : null;
1235
1245
  if (win) {
1236
- placed.startSec = seg.startSec + win.startSec;
1246
+ // A shot's lead may reach back into the segment before, never into a reaction beat or before the ad.
1247
+ const floor = Math.max(0, ...beats.filter((b) => b.startSec < seg.startSec).map((b) => b.startSec + b.seconds));
1248
+ placed.startSec = Math.max(floor, seg.startSec + win.startSec);
1237
1249
  placed.method = win.method;
1238
1250
  placed.seconds = c.source === "clip" ? Math.min(win.holdSec, c.seconds) : win.holdSec;
1239
1251
  placed.coversFound = win.found;
@@ -1241,7 +1253,6 @@ export function buildTimeline({
1241
1253
  cutaways.push(placed);
1242
1254
  }
1243
1255
  cutaways.sort((a, b) => a.startSec - b.startSec);
1244
- const beats = spine.filter((seg) => seg.reaction);
1245
1256
  for (let i = 0; i < cutaways.length; i++) {
1246
1257
  const c = cutaways[i];
1247
1258
  const stops = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.10.2300-beta.1",
3
+ "version": "2026.10.2400-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",