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

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.
@@ -15,7 +15,9 @@
15
15
  // the voice, trimmed when the clip runs longer and held on its last frame when
16
16
  // the voice runs longer. The voice itself is never altered. A dialogue scene
17
17
  // keeps the audio it performed; at each join its silence before the first word
18
- // and after the last is cut back to a short beat, read from its word times. A
18
+ // and after the last is cut back to a short beat, read from its word times.
19
+ // Inside it, a silence over 0.9 s between two sentences is cut down to 0.8 s,
20
+ // picture and sound together, unless the script writes that pause in. A
19
21
  // scene with no clip but a keyframe becomes a still for the length of its voice
20
22
  // track, else the storyboard's planned duration. Every segment is
21
23
  // loudness-normalized to -16 LUFS before the join.
@@ -75,6 +77,8 @@ export const REACTION_TAIL_GAIN = 0.35;
75
77
  export const JOIN_TAIL_SEC = 0.15;
76
78
  export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
77
79
  export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
80
+ /** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
81
+ const EXTRA_CLEARANCE_SEC = 0.05;
78
82
 
79
83
  /** The word rule the planner sized every line with (scout models.ts wordCount). */
80
84
  export function wordCount(text) {
@@ -90,6 +94,130 @@ export function lineWordCounts(storyboard) {
90
94
  return counts;
91
95
  }
92
96
 
97
+ const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
98
+
99
+ /**
100
+ * #2541: the indexes of the heard words that deliver the first and last
101
+ * scripted word, matching the script in order at its earliest delivery, or null
102
+ * when the whole script is not heard that way.
103
+ */
104
+ export function scriptedSpan(words, lineTexts) {
105
+ const script = lineTexts.flatMap((text) => (text ?? "").split(/\s+/)).map(wordToken).filter(Boolean);
106
+ const at = heardAt(words, script);
107
+ return at && at.length > 0 ? { first: at[0], last: at.at(-1) } : null;
108
+ }
109
+
110
+ /** The heard word that delivers each script token, in order at its earliest delivery, or null. */
111
+ function heardAt(words, tokens) {
112
+ const at = [];
113
+ let j = 0;
114
+ for (const token of tokens) {
115
+ while (j < words.length && wordToken(words[j].w) !== token) j++;
116
+ if (j === words.length) return null;
117
+ at.push(j++);
118
+ }
119
+ return at;
120
+ }
121
+
122
+ /** #2544: a silence between two sentences longer than this is cut down to the kept length. */
123
+ export const SENTENCE_GAP_MAX_SEC = 0.9;
124
+ export const SENTENCE_GAP_KEEP_SEC = 0.8;
125
+ /** A pause the script writes in: an ellipsis, a dash, or a hyphen standing between words. */
126
+ const WRITTEN_PAUSE = /…|\.{3}|[—–-]/;
127
+ const SENTENCE_END = /[.?!]/;
128
+ /** The directions that ask for a pause (scout clip-qc.ts PAUSE_DIRECTION, NO_PAUSE_DIRECTION, lineWrittenPause). */
129
+ const PAUSE_DIRECTION =
130
+ /\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;
131
+ const NO_PAUSE_DIRECTION =
132
+ /\b(no|not|without|never|don'?t|avoid\w*)\b[^.;]*\b(beats?|paus\w*|silence)\b|\bpause-?(free|less)\b/i;
133
+
134
+ export function asksPause(line) {
135
+ const direction = `${line?.stageDirection ?? ""} ${line?.performanceDirection ?? ""}`;
136
+ return PAUSE_DIRECTION.test(direction) && !NO_PAUSE_DIRECTION.test(direction);
137
+ }
138
+
139
+ /**
140
+ * #2544: the silences inside one clip to cut, as [{ s, e, after, gap }] in the
141
+ * clip's own time. A heard silence over SENTENCE_GAP_MAX_SEC between two
142
+ * scripted words at a sentence break (a new line, or . ? ! between them) keeps
143
+ * half of SENTENCE_GAP_KEEP_SEC after the earlier word and half before the
144
+ * later one; the rest goes. Kept: a pause the script writes in (a mark between
145
+ * the words, or a direction on either line asking for one), the laugh beat
146
+ * after a line a reaction is cued to, and any gap with another heard sound in
147
+ * it. Nothing is cut when the script is not heard in order.
148
+ *
149
+ * lines: [{ text, asksPause, cuesBeat }] in the order the clip speaks them.
150
+ */
151
+ export function sentenceGapCuts(words, lines) {
152
+ const tokens = [];
153
+ let between = "";
154
+ lines.forEach((line, li) => {
155
+ for (const piece of (line.text ?? "").split(/\s+/)) {
156
+ const token = wordToken(piece);
157
+ if (!token) {
158
+ between += ` ${piece}`;
159
+ continue;
160
+ }
161
+ const [, lead, trail] = piece.match(/^([^\p{L}\p{N}]*).*?([^\p{L}\p{N}]*)$/u);
162
+ tokens.push({ token, li, before: between + lead });
163
+ between = trail;
164
+ }
165
+ between += " ";
166
+ });
167
+ const at = heardAt(words ?? [], tokens.map((t) => t.token));
168
+ if (!at) return [];
169
+ const cuts = [];
170
+ for (let t = 0; t + 1 < tokens.length; t++) {
171
+ const [a, b] = [tokens[t], tokens[t + 1]];
172
+ if (at[t + 1] !== at[t] + 1) continue;
173
+ const newLine = a.li !== b.li;
174
+ if (!newLine && !SENTENCE_END.test(b.before)) continue;
175
+ if (WRITTEN_PAUSE.test(b.before) || lines[a.li].asksPause || lines[b.li].asksPause) continue;
176
+ if (newLine && lines[a.li].cuesBeat) continue;
177
+ const [earlier, later] = [words[at[t]], words[at[t + 1]]];
178
+ const gap = later.s - earlier.e;
179
+ if (gap <= SENTENCE_GAP_MAX_SEC + 1e-6) continue;
180
+ // Frame edges only ever widen what is kept, so the silence left stays
181
+ // between the kept length and the longest allowed.
182
+ const s = ceilFrame(earlier.e + SENTENCE_GAP_KEEP_SEC / 2);
183
+ const e = floorFrame(later.s - SENTENCE_GAP_KEEP_SEC / 2);
184
+ if (e > s) cuts.push({ s, e, after: earlier.w, gap });
185
+ }
186
+ return cuts;
187
+ }
188
+
189
+ /**
190
+ * Take each own-sound clip's long sentence gaps out of its timeline: its words
191
+ * move onto the cut timeline and it runs shorter, so every later step (join
192
+ * trims, reaction splits, cue times, captions) works in cut time. The renderer
193
+ * removes seg.cuts from the picture and the sound before anything else.
194
+ */
195
+ function tightenSentenceGaps(spine, reactionCues, scriptLines) {
196
+ for (const seg of spine) {
197
+ if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) continue;
198
+ const lines = seg.lineIds.map((id) => ({
199
+ text: scriptLines.get(id)?.text,
200
+ asksPause: asksPause(scriptLines.get(id)),
201
+ cuesBeat: reactionCues.has(id),
202
+ }));
203
+ const cuts = sentenceGapCuts(seg.words, lines);
204
+ if (cuts.length === 0) continue;
205
+ const removedBefore = (t) => cuts.reduce((sum, c) => sum + (c.e <= t ? c.e - c.s : 0), 0);
206
+ const removed = removedBefore(Infinity);
207
+ seg.cuts = cuts.map(({ s, e }) => ({ s, e }));
208
+ seg.words = seg.words.map((w) => ({ ...w, s: w.s - removedBefore(w.s), e: w.e - removedBefore(w.s) }));
209
+ seg.seconds -= removed;
210
+ seg.clipSeconds -= removed;
211
+ seg.note = withHow(
212
+ seg.note,
213
+ cuts.map((c) => `pause after "${c.after}" tightened ${c.gap.toFixed(2)}s -> ${(c.gap - (c.e - c.s)).toFixed(2)}s`),
214
+ );
215
+ }
216
+ }
217
+
218
+ const withHow = (note, how) =>
219
+ note.endsWith(")") ? `${note.slice(0, -1)}, ${how.join(", ")})` : `${note} (${how.join(", ")})`;
220
+
93
221
  const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
94
222
 
95
223
  const hasText = (value) => typeof value === "string" && value.trim().length > 0;
@@ -199,27 +327,49 @@ export function cueEndInSegment(seg, counts, cueLineId) {
199
327
  * and leaves the ad's first lead-in and last tail alone; a clip whose closing
200
328
  * line cues a reaction is not the ad's end, since the beat follows it. Trims
201
329
  * land on whole frames so the picture and its sound stay the same length.
330
+ *
331
+ * The beat is measured from the clip's scripted words when the script is heard
332
+ * in order, and a cut stops short of a sound made up before or after the line
333
+ * ("Hmm", "Um"), even at the ad's own edges (#2541). Sounds inside the line stay.
202
334
  */
203
- function trimJoins(spine, reactionCues) {
335
+ function trimJoins(spine, reactionCues, scriptLines) {
204
336
  spine.forEach((seg, k) => {
205
337
  if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
206
338
  const prev = spine[k - 1];
207
339
  const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
208
340
  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;
341
+ const span = scriptedSpan(seg.words, seg.lineIds.map((id) => scriptLines.get(id)?.text));
342
+ const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
343
+ const before = span ? seg.words.slice(0, span.first) : [];
344
+ const after = span ? seg.words.slice(span.last + 1) : [];
345
+ const firstWord = Math.min(...line.map((w) => w.s));
346
+ const lastWord = Math.max(...line.map((w) => w.e));
347
+ let inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
348
+ if (before.length > 0) {
349
+ const clear = ceilFrame(Math.max(...before.map((w) => w.e)) + EXTRA_CLEARANCE_SEC);
350
+ inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
351
+ }
212
352
  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));
353
+ let outSec = endsAd
354
+ ? after.length > 0 ? ceilFrame(lastWord + TAIL_AIR_SEC) : seg.seconds
355
+ : ceilFrame(lastWord + JOIN_TAIL_SEC);
356
+ if (after.length > 0) {
357
+ const clear = floorFrame(Math.min(...after.map((w) => w.s)) - EXTRA_CLEARANCE_SEC);
358
+ outSec = Math.min(outSec, Math.max(clear, ceilFrame(lastWord)));
359
+ }
360
+ outSec = Math.min(seg.seconds, outSec);
214
361
  if (outSec <= inSec) return;
215
362
  const how = [];
216
363
  if (inSec > 0) how.push(`lead-in trimmed ${inSec.toFixed(2)}s`);
217
364
  if (outSec < seg.seconds) how.push(`tail trimmed ${(seg.seconds - outSec).toFixed(2)}s`);
218
365
  if (how.length === 0) return;
366
+ const kept = seg.words.filter((w) => w.s < outSec && w.e > inSec);
367
+ if (before.some((w) => !kept.includes(w))) how.push("extra sound before the line trimmed");
368
+ if (after.some((w) => !kept.includes(w))) how.push("extra sound after the line trimmed");
219
369
  seg.inSec = inSec;
220
370
  seg.seconds = outSec - inSec;
221
- seg.words = seg.words.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
222
- seg.note = `${seg.note} (${how.join(", ")})`;
371
+ seg.words = kept.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
372
+ seg.note = withHow(seg.note, how);
223
373
  });
224
374
  }
225
375
 
@@ -382,10 +532,13 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
382
532
  const noCue = (c) =>
383
533
  c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
384
534
 
385
- // Joins are closed first so a reaction beat lands right after its cue line
386
- // ends in the trimmed clip. Reactions change the spine, so they go in before
387
- // any overlay is placed on it.
388
- trimJoins(spine, new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId)));
535
+ // Sentence gaps and then joins are closed first so a reaction beat lands
536
+ // right after its cue line ends in the trimmed clip. Reactions change the
537
+ // spine, so they go in before any overlay is placed on it.
538
+ const scriptLines = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line]));
539
+ const reactionCues = new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId));
540
+ tightenSentenceGaps(spine, reactionCues, scriptLines);
541
+ trimJoins(spine, reactionCues, scriptLines);
389
542
  for (const c of waiting.filter((w) => w.reaction)) {
390
543
  if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
391
544
  dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
@@ -456,6 +609,25 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
456
609
  : addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
457
610
  const fit = [];
458
611
  const inSec = seg.inSec ?? 0;
612
+ // #2544: sentence gaps come out first, the picture by frame number and the
613
+ // sound by the same times, so everything after works on the cut timeline.
614
+ const cuts = seg.cuts ?? [];
615
+ let heard = `[${i}:a]${stereo},`;
616
+ if (cuts.length > 0) {
617
+ const frames = cuts.map((c) => `between(n\\,${Math.round(c.s * FPS)}\\,${Math.round(c.e * FPS) - 1})`);
618
+ fit.push(`select=not(${frames.join("+")}),setpts=N/FRAME_RATE/TB`);
619
+ const kept = [0, ...cuts.flatMap((c) => [c.s, c.e])];
620
+ const pieces = cuts.length + 1;
621
+ filters.push(`[${i}:a]${stereo},asplit=${pieces}${Array.from({ length: pieces }, (_, p) => `[ap${i}_${p}]`).join("")}`);
622
+ for (let p = 0; p < pieces; p++) {
623
+ const end = p < cuts.length ? `:end=${kept[2 * p + 1].toFixed(6)}` : "";
624
+ filters.push(`[ap${i}_${p}]atrim=start=${kept[2 * p].toFixed(6)}${end},asetpts=PTS-STARTPTS[aq${i}_${p}]`);
625
+ }
626
+ filters.push(
627
+ `${Array.from({ length: pieces }, (_, p) => `[aq${i}_${p}]`).join("")}concat=n=${pieces}:v=0:a=1[ac${i}]`,
628
+ );
629
+ heard = `[ac${i}]`;
630
+ }
459
631
  if (seg.source === "clip" && seg.clipSeconds !== null) {
460
632
  const shown = seg.seconds - seg.holdSec;
461
633
  if (inSec > 0.001 || shown < seg.clipSeconds - 0.01) {
@@ -481,7 +653,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
481
653
  // ducked, faded out, and laid in at the join; the next line's own sound
482
654
  // plays at full level over it (#2406).
483
655
  const t = seg.reaction.tail;
484
- filters.push(`[${i}:a]${stereo},${skipIn}${LOUDNORM},asplit=2[as${i}][at${i}]`);
656
+ filters.push(`${heard}${skipIn}${LOUDNORM},asplit=2[as${i}][at${i}]`);
485
657
  filters.push(`[as${i}]${fitLength}[a${i}]`);
486
658
  filters.push(
487
659
  `[at${i}]atrim=start=${sec(t.fromSec - inSec)}:duration=${sec(t.seconds)},asetpts=PTS-STARTPTS,` +
@@ -490,7 +662,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
490
662
  );
491
663
  tails.push(`[tail${i}]`);
492
664
  } else if (seg.audio === "clip") {
493
- filters.push(`[${i}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
665
+ filters.push(`${heard}${skipIn}${fitSound}[a${i}]`);
494
666
  } else {
495
667
  filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
496
668
  }
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.19",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",