@aicopycoders/exodus 2026.9.2000-beta.18 → 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.
@@ -101,18 +103,121 @@ const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{
101
103
  */
102
104
  export function scriptedSpan(words, lineTexts) {
103
105
  const script = lineTexts.flatMap((text) => (text ?? "").split(/\s+/)).map(wordToken).filter(Boolean);
104
- if (script.length === 0) return null;
105
- let first = -1;
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 = [];
106
113
  let j = 0;
107
- for (const token of script) {
114
+ for (const token of tokens) {
108
115
  while (j < words.length && wordToken(words[j].w) !== token) j++;
109
116
  if (j === words.length) return null;
110
- if (first < 0) first = j;
111
- j++;
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 });
112
185
  }
113
- return { first, last: j - 1 };
186
+ return cuts;
114
187
  }
115
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
+
116
221
  const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
117
222
 
118
223
  const hasText = (value) => typeof value === "string" && value.trim().length > 0;
@@ -227,13 +332,13 @@ export function cueEndInSegment(seg, counts, cueLineId) {
227
332
  * in order, and a cut stops short of a sound made up before or after the line
228
333
  * ("Hmm", "Um"), even at the ad's own edges (#2541). Sounds inside the line stay.
229
334
  */
230
- function trimJoins(spine, reactionCues, lineTexts) {
335
+ function trimJoins(spine, reactionCues, scriptLines) {
231
336
  spine.forEach((seg, k) => {
232
337
  if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
233
338
  const prev = spine[k - 1];
234
339
  const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
235
340
  const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
236
- const span = scriptedSpan(seg.words, seg.lineIds.map((id) => lineTexts.get(id)));
341
+ const span = scriptedSpan(seg.words, seg.lineIds.map((id) => scriptLines.get(id)?.text));
237
342
  const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
238
343
  const before = span ? seg.words.slice(0, span.first) : [];
239
344
  const after = span ? seg.words.slice(span.last + 1) : [];
@@ -264,7 +369,7 @@ function trimJoins(spine, reactionCues, lineTexts) {
264
369
  seg.inSec = inSec;
265
370
  seg.seconds = outSec - inSec;
266
371
  seg.words = kept.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
267
- seg.note = `${seg.note} (${how.join(", ")})`;
372
+ seg.note = withHow(seg.note, how);
268
373
  });
269
374
  }
270
375
 
@@ -427,11 +532,13 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
427
532
  const noCue = (c) =>
428
533
  c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
429
534
 
430
- // Joins are closed first so a reaction beat lands right after its cue line
431
- // ends in the trimmed clip. Reactions change the spine, so they go in before
432
- // any overlay is placed on it.
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);
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);
435
542
  for (const c of waiting.filter((w) => w.reaction)) {
436
543
  if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
437
544
  dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
@@ -502,6 +609,25 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
502
609
  : addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
503
610
  const fit = [];
504
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
+ }
505
631
  if (seg.source === "clip" && seg.clipSeconds !== null) {
506
632
  const shown = seg.seconds - seg.holdSec;
507
633
  if (inSec > 0.001 || shown < seg.clipSeconds - 0.01) {
@@ -527,7 +653,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
527
653
  // ducked, faded out, and laid in at the join; the next line's own sound
528
654
  // plays at full level over it (#2406).
529
655
  const t = seg.reaction.tail;
530
- 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}]`);
531
657
  filters.push(`[as${i}]${fitLength}[a${i}]`);
532
658
  filters.push(
533
659
  `[at${i}]atrim=start=${sec(t.fromSec - inSec)}:duration=${sec(t.seconds)},asetpts=PTS-STARTPTS,` +
@@ -536,7 +662,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
536
662
  );
537
663
  tails.push(`[tail${i}]`);
538
664
  } else if (seg.audio === "clip") {
539
- filters.push(`[${i}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
665
+ filters.push(`${heard}${skipIn}${fitSound}[a${i}]`);
540
666
  } else {
541
667
  filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
542
668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.18",
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",