@aicopycoders/exodus 2026.9.2000-beta.17 → 2026.9.2000-beta.18

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.
@@ -75,6 +75,8 @@ export const REACTION_TAIL_GAIN = 0.35;
75
75
  export const JOIN_TAIL_SEC = 0.15;
76
76
  export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
77
77
  export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
78
+ /** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
79
+ const EXTRA_CLEARANCE_SEC = 0.05;
78
80
 
79
81
  /** The word rule the planner sized every line with (scout models.ts wordCount). */
80
82
  export function wordCount(text) {
@@ -90,6 +92,27 @@ export function lineWordCounts(storyboard) {
90
92
  return counts;
91
93
  }
92
94
 
95
+ const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
96
+
97
+ /**
98
+ * #2541: the indexes of the heard words that deliver the first and last
99
+ * scripted word, matching the script in order at its earliest delivery, or null
100
+ * when the whole script is not heard that way.
101
+ */
102
+ export function scriptedSpan(words, lineTexts) {
103
+ const script = lineTexts.flatMap((text) => (text ?? "").split(/\s+/)).map(wordToken).filter(Boolean);
104
+ if (script.length === 0) return null;
105
+ let first = -1;
106
+ let j = 0;
107
+ for (const token of script) {
108
+ while (j < words.length && wordToken(words[j].w) !== token) j++;
109
+ if (j === words.length) return null;
110
+ if (first < 0) first = j;
111
+ j++;
112
+ }
113
+ return { first, last: j - 1 };
114
+ }
115
+
93
116
  const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
94
117
 
95
118
  const hasText = (value) => typeof value === "string" && value.trim().length > 0;
@@ -199,26 +222,48 @@ export function cueEndInSegment(seg, counts, cueLineId) {
199
222
  * and leaves the ad's first lead-in and last tail alone; a clip whose closing
200
223
  * line cues a reaction is not the ad's end, since the beat follows it. Trims
201
224
  * land on whole frames so the picture and its sound stay the same length.
225
+ *
226
+ * The beat is measured from the clip's scripted words when the script is heard
227
+ * in order, and a cut stops short of a sound made up before or after the line
228
+ * ("Hmm", "Um"), even at the ad's own edges (#2541). Sounds inside the line stay.
202
229
  */
203
- function trimJoins(spine, reactionCues) {
230
+ function trimJoins(spine, reactionCues, lineTexts) {
204
231
  spine.forEach((seg, k) => {
205
232
  if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
206
233
  const prev = spine[k - 1];
207
234
  const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
208
235
  const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
209
- const firstWord = Math.min(...seg.words.map((w) => w.s));
210
- const lastWord = Math.max(...seg.words.map((w) => w.e));
211
- const inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
236
+ const span = scriptedSpan(seg.words, seg.lineIds.map((id) => lineTexts.get(id)));
237
+ const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
238
+ const before = span ? seg.words.slice(0, span.first) : [];
239
+ const after = span ? seg.words.slice(span.last + 1) : [];
240
+ const firstWord = Math.min(...line.map((w) => w.s));
241
+ const lastWord = Math.max(...line.map((w) => w.e));
242
+ let inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
243
+ if (before.length > 0) {
244
+ const clear = ceilFrame(Math.max(...before.map((w) => w.e)) + EXTRA_CLEARANCE_SEC);
245
+ inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
246
+ }
212
247
  const endsAd = k === spine.length - 1 && !reactionCues.has(seg.lineIds.at(-1));
213
- const outSec = endsAd ? seg.seconds : Math.min(seg.seconds, ceilFrame(lastWord + JOIN_TAIL_SEC));
248
+ let outSec = endsAd
249
+ ? after.length > 0 ? ceilFrame(lastWord + TAIL_AIR_SEC) : seg.seconds
250
+ : ceilFrame(lastWord + JOIN_TAIL_SEC);
251
+ if (after.length > 0) {
252
+ const clear = floorFrame(Math.min(...after.map((w) => w.s)) - EXTRA_CLEARANCE_SEC);
253
+ outSec = Math.min(outSec, Math.max(clear, ceilFrame(lastWord)));
254
+ }
255
+ outSec = Math.min(seg.seconds, outSec);
214
256
  if (outSec <= inSec) return;
215
257
  const how = [];
216
258
  if (inSec > 0) how.push(`lead-in trimmed ${inSec.toFixed(2)}s`);
217
259
  if (outSec < seg.seconds) how.push(`tail trimmed ${(seg.seconds - outSec).toFixed(2)}s`);
218
260
  if (how.length === 0) return;
261
+ const kept = seg.words.filter((w) => w.s < outSec && w.e > inSec);
262
+ if (before.some((w) => !kept.includes(w))) how.push("extra sound before the line trimmed");
263
+ if (after.some((w) => !kept.includes(w))) how.push("extra sound after the line trimmed");
219
264
  seg.inSec = inSec;
220
265
  seg.seconds = outSec - inSec;
221
- seg.words = seg.words.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
266
+ seg.words = kept.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
222
267
  seg.note = `${seg.note} (${how.join(", ")})`;
223
268
  });
224
269
  }
@@ -385,7 +430,8 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
385
430
  // Joins are closed first so a reaction beat lands right after its cue line
386
431
  // ends in the trimmed clip. Reactions change the spine, so they go in before
387
432
  // any overlay is placed on it.
388
- trimJoins(spine, new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId)));
433
+ const lineTexts = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line?.text]));
434
+ trimJoins(spine, new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId)), lineTexts);
389
435
  for (const c of waiting.filter((w) => w.reaction)) {
390
436
  if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
391
437
  dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.17",
3
+ "version": "2026.9.2000-beta.18",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",