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

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.
@@ -18,6 +18,8 @@
18
18
  // and after the last is cut back to a short beat, read from its word times.
19
19
  // Inside it, a silence over 0.9 s between two sentences is cut down to 0.8 s,
20
20
  // picture and sound together, unless the script writes that pause in. A
21
+ // sound in such a break that holds no heard word (a burp, a click) is cut
22
+ // out, read from the clip's loudness, and the same rule then applies. A
21
23
  // scene with no clip but a keyframe becomes a still for the length of its voice
22
24
  // track, else the storyboard's planned duration. Every segment is
23
25
  // loudness-normalized to -16 LUFS before the join.
@@ -125,6 +127,14 @@ export const SENTENCE_GAP_KEEP_SEC = 0.8;
125
127
  /** A pause the script writes in: an ellipsis, a dash, or a hyphen standing between words. */
126
128
  const WRITTEN_PAUSE = /…|\.{3}|[—–-]/;
127
129
  const SENTENCE_END = /[.?!]/;
130
+ /**
131
+ * #2554: loudness windows, and the levels that read as a sound and as quiet;
132
+ * SETTLED_WINDOWS quiet windows in a row (200 ms) end a sound (scout trim.ts).
133
+ */
134
+ const LEVEL_WINDOW_SEC = 0.02;
135
+ const SOUND_DB = -35;
136
+ const QUIET_DB = -50;
137
+ const SETTLED_WINDOWS = 10;
128
138
  /** The directions that ask for a pause (scout clip-qc.ts PAUSE_DIRECTION, NO_PAUSE_DIRECTION, lineWrittenPause). */
129
139
  const PAUSE_DIRECTION =
130
140
  /\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;
@@ -146,9 +156,14 @@ export function asksPause(line) {
146
156
  * after a line a reaction is cued to, and any gap with another heard sound in
147
157
  * it. Nothing is cut when the script is not heard in order.
148
158
  *
159
+ * #2554: given the clip's loudness ({ windowSec, db } from 0), a sound in a
160
+ * break that holds no heard word is cut too, whatever the break's length, and
161
+ * the silence left follows the same rule. Every span of one break carries that
162
+ * break's gap, the silence it keeps, and whether a sound went.
163
+ *
149
164
  * lines: [{ text, asksPause, cuesBeat }] in the order the clip speaks them.
150
165
  */
151
- export function sentenceGapCuts(words, lines) {
166
+ export function sentenceGapCuts(words, lines, levels = null) {
152
167
  const tokens = [];
153
168
  let between = "";
154
169
  lines.forEach((line, li) => {
@@ -176,23 +191,98 @@ export function sentenceGapCuts(words, lines) {
176
191
  if (newLine && lines[a.li].cuesBeat) continue;
177
192
  const [earlier, later] = [words[at[t]], words[at[t + 1]]];
178
193
  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 });
194
+ const heard = levels && soundBetween(levels, earlier.e, later.s);
195
+ // A sound's frame edges widen the cut, so none of it is left in.
196
+ const sound = heard && { s: Math.max(floorFrame(heard.s), ceilFrame(earlier.e)), e: ceilFrame(heard.e) };
197
+ const removed = sound ? sound.e - sound.s : 0;
198
+ const spans = sound ? [sound] : [];
199
+ if (gap - removed > SENTENCE_GAP_MAX_SEC + 1e-6) {
200
+ // Placed on the gap with the sound already out, then moved back past it.
201
+ // Frame edges only ever widen what is kept, so the silence left stays
202
+ // between the kept length and the longest allowed.
203
+ const back = (t) => (sound && t > sound.s ? t + removed : t);
204
+ const s = back(ceilFrame(earlier.e + SENTENCE_GAP_KEEP_SEC / 2));
205
+ const e = back(floorFrame(later.s - removed - SENTENCE_GAP_KEEP_SEC / 2));
206
+ if (e > s) spans.push({ s, e });
207
+ }
208
+ const merged = [];
209
+ for (const span of spans.sort((x, y) => x.s - y.s)) {
210
+ const prev = merged.at(-1);
211
+ if (prev && span.s <= prev.e + 1e-6) prev.e = Math.max(prev.e, span.e);
212
+ else merged.push({ ...span });
213
+ }
214
+ const kept = gap - merged.reduce((sum, c) => sum + c.e - c.s, 0);
215
+ for (const { s, e } of merged) cuts.push({ s, e, after: earlier.w, gap, kept, sound: Boolean(sound) });
185
216
  }
186
217
  return cuts;
187
218
  }
188
219
 
220
+ /**
221
+ * #2554: a sound between two words that holds no heard word, from where the
222
+ * earlier word fades under QUIET_DB to where SETTLED_WINDOWS of quiet resume
223
+ * before the later word, or null when there is none or it runs into the later
224
+ * word. Mirrors scout trim.ts soundBetween.
225
+ */
226
+ function soundBetween(levels, fromSec, toSec) {
227
+ const { db, windowSec } = levels;
228
+ const windowAt = (sec) => Math.floor(sec / windowSec + 1e-9);
229
+ const last = Math.min(windowAt(toSec), db.length);
230
+ let quiet = windowAt(fromSec);
231
+ while (quiet < last && db[quiet] >= QUIET_DB) quiet++;
232
+ let resumed = -1;
233
+ for (let i = quiet; i < last; i = resumed + SETTLED_WINDOWS) {
234
+ while (i < last && db[i] < SOUND_DB) i++;
235
+ if (i >= last) break;
236
+ const settled = settledQuiet(db, i);
237
+ if (settled < 0 || settled + SETTLED_WINDOWS > last) break;
238
+ resumed = settled;
239
+ }
240
+ return resumed < 0 ? null : { s: quiet * windowSec, e: resumed * windowSec };
241
+ }
242
+
243
+ /** Scanning forward from `from`, the first window of the first run of SETTLED_WINDOWS quiet windows, or -1. */
244
+ function settledQuiet(db, from) {
245
+ let run = 0;
246
+ for (let i = from; i < db.length; i++) {
247
+ run = db[i] < QUIET_DB ? run + 1 : 0;
248
+ if (run === SETTLED_WINDOWS) return i - (SETTLED_WINDOWS - 1);
249
+ }
250
+ return -1;
251
+ }
252
+
253
+ /** Per-20 ms RMS loudness (dBFS) of mono s16 PCM, digital silence reading -120 (scout ffmpeg-clip.ts audioLevelsFromPcm). */
254
+ export function levelsFromPcm(pcm, sampleRate) {
255
+ const perWindow = Math.round(sampleRate * LEVEL_WINDOW_SEC);
256
+ const db = [];
257
+ for (let from = 0; from < pcm.length; from += perWindow) {
258
+ const to = Math.min(pcm.length, from + perWindow);
259
+ let sumSq = 0;
260
+ for (let i = from; i < to; i++) sumSq += pcm[i] * pcm[i];
261
+ const rms = Math.sqrt(sumSq / (to - from)) / 32768;
262
+ db.push(rms > 0 ? Math.max(-120, 20 * Math.log10(rms)) : -120);
263
+ }
264
+ return { windowSec: LEVEL_WINDOW_SEC, db };
265
+ }
266
+
267
+ /** A clip's loudness windows, or null when its audio cannot be decoded. */
268
+ function clipLevels(file) {
269
+ const res = spawnSync(
270
+ "ffmpeg",
271
+ ["-v", "error", "-i", file, "-map", "0:a:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"],
272
+ { maxBuffer: 1 << 28 },
273
+ );
274
+ if (res.status !== 0 || !res.stdout?.length) return null;
275
+ const bytes = new Uint8Array(res.stdout.subarray(0, res.stdout.length & ~1));
276
+ return levelsFromPcm(new Int16Array(bytes.buffer), 16000);
277
+ }
278
+
189
279
  /**
190
280
  * Take each own-sound clip's long sentence gaps out of its timeline: its words
191
281
  * move onto the cut timeline and it runs shorter, so every later step (join
192
282
  * trims, reaction splits, cue times, captions) works in cut time. The renderer
193
283
  * removes seg.cuts from the picture and the sound before anything else.
194
284
  */
195
- function tightenSentenceGaps(spine, reactionCues, scriptLines) {
285
+ function tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels) {
196
286
  for (const seg of spine) {
197
287
  if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) continue;
198
288
  const lines = seg.lineIds.map((id) => ({
@@ -200,7 +290,7 @@ function tightenSentenceGaps(spine, reactionCues, scriptLines) {
200
290
  asksPause: asksPause(scriptLines.get(id)),
201
291
  cuesBeat: reactionCues.has(id),
202
292
  }));
203
- const cuts = sentenceGapCuts(seg.words, lines);
293
+ const cuts = sentenceGapCuts(seg.words, lines, readLevels(seg.file));
204
294
  if (cuts.length === 0) continue;
205
295
  const removedBefore = (t) => cuts.reduce((sum, c) => sum + (c.e <= t ? c.e - c.s : 0), 0);
206
296
  const removed = removedBefore(Infinity);
@@ -210,7 +300,15 @@ function tightenSentenceGaps(spine, reactionCues, scriptLines) {
210
300
  seg.clipSeconds -= removed;
211
301
  seg.note = withHow(
212
302
  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`),
303
+ [
304
+ ...new Set(
305
+ cuts.map((c) =>
306
+ c.sound
307
+ ? `sound after "${c.after}" cut, pause ${c.gap.toFixed(2)}s -> ${c.kept.toFixed(2)}s`
308
+ : `pause after "${c.after}" tightened ${c.gap.toFixed(2)}s -> ${c.kept.toFixed(2)}s`,
309
+ ),
310
+ ),
311
+ ],
214
312
  );
215
313
  }
216
314
  }
@@ -433,7 +531,7 @@ function reactionTail(seg, next) {
433
531
  return { atSec: next.startSec, fromSec, seconds, gain: REACTION_TAIL_GAIN };
434
532
  }
435
533
 
436
- export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
534
+ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords, readLevels = () => null }) {
437
535
  const plans = scenePlans(storyboard);
438
536
  const counts = lineWordCounts(storyboard);
439
537
  const spine = [];
@@ -537,7 +635,7 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
537
635
  // spine, so they go in before any overlay is placed on it.
538
636
  const scriptLines = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line]));
539
637
  const reactionCues = new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId));
540
- tightenSentenceGaps(spine, reactionCues, scriptLines);
638
+ tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels);
541
639
  trimJoins(spine, reactionCues, scriptLines);
542
640
  for (const c of waiting.filter((w) => w.reaction)) {
543
641
  if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
@@ -902,6 +1000,7 @@ function main() {
902
1000
  const rows = readJson(file);
903
1001
  return Array.isArray(rows) && rows.length > 0 ? rows : null;
904
1002
  },
1003
+ readLevels: (file) => clipLevels(here(file)),
905
1004
  });
906
1005
  if (timeline.spine.length === 0) {
907
1006
  fail(1, `No scene has a clip or a keyframe. Check: exodus video status ${manifest.runId}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.19",
3
+ "version": "2026.9.2000-beta.20",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",