@aicopycoders/exodus 2026.9.2000-beta.2 → 2026.9.2000-beta.21

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.
@@ -2,7 +2,10 @@
2
2
  // A plain first cut from a folder written by `exodus video pull`: the A-roll
3
3
  // scenes in the manifest's order make the spine, each cutaway lays over the
4
4
  // spine at the moment its cued line is spoken, a room-tone bed and the music
5
- // bed go underneath, and one MP4 comes out ready to upload.
5
+ // bed go underneath, and one MP4 comes out ready to upload. A reaction
6
+ // cutaway is the exception: it is its own short beat in the spine right after
7
+ // its cued line ends, heard with its own sound while the speaker is silent,
8
+ // and its sound fades out quietly under the next line rather than stopping.
6
9
  //
7
10
  // node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
8
11
  //
@@ -11,9 +14,15 @@
11
14
  // recorded: the voice replaces the clip's audio, and the picture is fitted to
12
15
  // the voice, trimmed when the clip runs longer and held on its last frame when
13
16
  // the voice runs longer. The voice itself is never altered. A dialogue scene
14
- // keeps the audio it performed. A scene with no clip but a keyframe becomes a
15
- // still for the length of its voice track, else the storyboard's planned
16
- // duration. Every segment is loudness-normalized to -16 LUFS before the join.
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.
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
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
23
+ // scene with no clip but a keyframe becomes a still for the length of its voice
24
+ // track, else the storyboard's planned duration. Every segment is
25
+ // loudness-normalized to -16 LUFS before the join.
17
26
  // --skip drops scenes.
18
27
  //
19
28
  // After the render it writes the whole ad's word times beside the MP4, as
@@ -30,15 +39,48 @@ const FPS = 24;
30
39
  const norm = (label) =>
31
40
  `[${label}]scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
32
41
  `pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS},format=yuv420p`;
42
+ /**
43
+ * Edges the cut chooses (join trims, reaction splits, narration lengths) land
44
+ * on whole frames, so the picture runs as long as its sound there and the
45
+ * timeline's times are the cut's times.
46
+ */
47
+ const floorFrame = (t) => Math.floor(t * FPS + 1e-6) / FPS;
48
+ const ceilFrame = (t) => Math.ceil(t * FPS - 1e-6) / FPS;
33
49
  const stereo = "aresample=48000,aformat=channel_layouts=stereo";
34
- /** Per-segment loudness, the level every clip was normalized to before the join. */
35
- const LOUDNORM = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
50
+ /**
51
+ * Per-segment loudness, the level every clip was normalized to before the join.
52
+ * loudnorm stamps its output about 0.1 s late (its look-ahead), so a later
53
+ * atrim by time kept that much less sound than picture and every join pulled
54
+ * the sound further ahead (#2387). Re-stamping by sample count undoes it.
55
+ */
56
+ const LOUDNORM = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000,asetpts=N/SR/TB";
57
+ /** A fade this short at each edge of a segment's sound stops a hard cut in the room tone clicking. */
58
+ const EDGE_FADE_SEC = 0.015;
36
59
  /** Air after the last spoken word of a narrated scene, so the line lands. */
37
60
  export const TAIL_AIR_SEC = 0.8;
38
61
  /** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
39
62
  const ROOM_TONE =
40
63
  "anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
41
64
  const MUSIC_VOLUME = 0.18;
65
+ /** The longest a reaction beat holds the spine (#2357); older pulls carry 4 s reaction clips. */
66
+ export const REACTION_MAX_SEC = 1.5;
67
+ /**
68
+ * A reaction's laugh carries into the next line instead of stopping dead at
69
+ * the join (#2406): it starts well under the speaker, at this share of its own
70
+ * level (about -9 dB), and fades to silence over this long.
71
+ */
72
+ export const REACTION_TAIL_SEC = 0.75;
73
+ export const REACTION_TAIL_GAIN = 0.35;
74
+ /**
75
+ * The air a join keeps around its words (#2388): after one clip's last word, and
76
+ * before the next clip's first, less when the same speaker carries on than when
77
+ * someone new answers.
78
+ */
79
+ export const JOIN_TAIL_SEC = 0.15;
80
+ export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
81
+ export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
82
+ /** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
83
+ const EXTRA_CLEARANCE_SEC = 0.05;
42
84
 
43
85
  /** The word rule the planner sized every line with (scout models.ts wordCount). */
44
86
  export function wordCount(text) {
@@ -54,6 +96,226 @@ export function lineWordCounts(storyboard) {
54
96
  return counts;
55
97
  }
56
98
 
99
+ const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
100
+
101
+ /**
102
+ * #2541: the indexes of the heard words that deliver the first and last
103
+ * scripted word, matching the script in order at its earliest delivery, or null
104
+ * when the whole script is not heard that way.
105
+ */
106
+ export function scriptedSpan(words, lineTexts) {
107
+ const script = lineTexts.flatMap((text) => (text ?? "").split(/\s+/)).map(wordToken).filter(Boolean);
108
+ const at = heardAt(words, script);
109
+ return at && at.length > 0 ? { first: at[0], last: at.at(-1) } : null;
110
+ }
111
+
112
+ /** The heard word that delivers each script token, in order at its earliest delivery, or null. */
113
+ function heardAt(words, tokens) {
114
+ const at = [];
115
+ let j = 0;
116
+ for (const token of tokens) {
117
+ while (j < words.length && wordToken(words[j].w) !== token) j++;
118
+ if (j === words.length) return null;
119
+ at.push(j++);
120
+ }
121
+ return at;
122
+ }
123
+
124
+ /** #2544: a silence between two sentences longer than this is cut down to the kept length. */
125
+ export const SENTENCE_GAP_MAX_SEC = 0.9;
126
+ export const SENTENCE_GAP_KEEP_SEC = 0.8;
127
+ /** A pause the script writes in: an ellipsis, a dash, or a hyphen standing between words. */
128
+ const WRITTEN_PAUSE = /…|\.{3}|[—–-]/;
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;
138
+ /** The directions that ask for a pause (scout clip-qc.ts PAUSE_DIRECTION, NO_PAUSE_DIRECTION, lineWrittenPause). */
139
+ const PAUSE_DIRECTION =
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;
141
+ const NO_PAUSE_DIRECTION =
142
+ /\b(no|not|without|never|don'?t|avoid\w*)\b[^.;]*\b(beats?|paus\w*|silence)\b|\bpause-?(free|less)\b/i;
143
+
144
+ export function asksPause(line) {
145
+ const direction = `${line?.stageDirection ?? ""} ${line?.performanceDirection ?? ""}`;
146
+ return PAUSE_DIRECTION.test(direction) && !NO_PAUSE_DIRECTION.test(direction);
147
+ }
148
+
149
+ /**
150
+ * #2544: the silences inside one clip to cut, as [{ s, e, after, gap }] in the
151
+ * clip's own time. A heard silence over SENTENCE_GAP_MAX_SEC between two
152
+ * scripted words at a sentence break (a new line, or . ? ! between them) keeps
153
+ * half of SENTENCE_GAP_KEEP_SEC after the earlier word and half before the
154
+ * later one; the rest goes. Kept: a pause the script writes in (a mark between
155
+ * the words, or a direction on either line asking for one), the laugh beat
156
+ * after a line a reaction is cued to, and any gap with another heard sound in
157
+ * it. Nothing is cut when the script is not heard in order.
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
+ *
164
+ * lines: [{ text, asksPause, cuesBeat }] in the order the clip speaks them.
165
+ */
166
+ export function sentenceGapCuts(words, lines, levels = null) {
167
+ const tokens = [];
168
+ let between = "";
169
+ lines.forEach((line, li) => {
170
+ for (const piece of (line.text ?? "").split(/\s+/)) {
171
+ const token = wordToken(piece);
172
+ if (!token) {
173
+ between += ` ${piece}`;
174
+ continue;
175
+ }
176
+ const [, lead, trail] = piece.match(/^([^\p{L}\p{N}]*).*?([^\p{L}\p{N}]*)$/u);
177
+ tokens.push({ token, li, before: between + lead });
178
+ between = trail;
179
+ }
180
+ between += " ";
181
+ });
182
+ const at = heardAt(words ?? [], tokens.map((t) => t.token));
183
+ if (!at) return [];
184
+ const cuts = [];
185
+ for (let t = 0; t + 1 < tokens.length; t++) {
186
+ const [a, b] = [tokens[t], tokens[t + 1]];
187
+ if (at[t + 1] !== at[t] + 1) continue;
188
+ const newLine = a.li !== b.li;
189
+ if (!newLine && !SENTENCE_END.test(b.before)) continue;
190
+ if (WRITTEN_PAUSE.test(b.before) || lines[a.li].asksPause || lines[b.li].asksPause) continue;
191
+ if (newLine && lines[a.li].cuesBeat) continue;
192
+ const [earlier, later] = [words[at[t]], words[at[t + 1]]];
193
+ const gap = later.s - earlier.e;
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) });
216
+ }
217
+ return cuts;
218
+ }
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
+
279
+ /**
280
+ * Take each own-sound clip's long sentence gaps out of its timeline: its words
281
+ * move onto the cut timeline and it runs shorter, so every later step (join
282
+ * trims, reaction splits, cue times, captions) works in cut time. The renderer
283
+ * removes seg.cuts from the picture and the sound before anything else.
284
+ */
285
+ function tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels) {
286
+ for (const seg of spine) {
287
+ if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) continue;
288
+ const lines = seg.lineIds.map((id) => ({
289
+ text: scriptLines.get(id)?.text,
290
+ asksPause: asksPause(scriptLines.get(id)),
291
+ cuesBeat: reactionCues.has(id),
292
+ }));
293
+ const cuts = sentenceGapCuts(seg.words, lines, readLevels(seg.file));
294
+ if (cuts.length === 0) continue;
295
+ const removedBefore = (t) => cuts.reduce((sum, c) => sum + (c.e <= t ? c.e - c.s : 0), 0);
296
+ const removed = removedBefore(Infinity);
297
+ seg.cuts = cuts.map(({ s, e }) => ({ s, e }));
298
+ seg.words = seg.words.map((w) => ({ ...w, s: w.s - removedBefore(w.s), e: w.e - removedBefore(w.s) }));
299
+ seg.seconds -= removed;
300
+ seg.clipSeconds -= removed;
301
+ seg.note = withHow(
302
+ seg.note,
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
+ ],
312
+ );
313
+ }
314
+ }
315
+
316
+ const withHow = (note, how) =>
317
+ note.endsWith(")") ? `${note.slice(0, -1)}, ${how.join(", ")})` : `${note} (${how.join(", ")})`;
318
+
57
319
  const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
58
320
 
59
321
  const hasText = (value) => typeof value === "string" && value.trim().length > 0;
@@ -77,11 +339,17 @@ export function scenePlans(storyboard) {
77
339
  const plans = new Map();
78
340
  for (const scene of storyboard?.scenes ?? []) {
79
341
  if (typeof scene?.sceneIndex !== "number") continue;
342
+ const speakers = (Array.isArray(scene.dialogue) ? scene.dialogue : [])
343
+ .filter((turn) => hasText(turn?.line) || hasText(turn?.text))
344
+ .map((turn) => (hasText(turn.speaker) ? turn.speaker.trim() : null));
80
345
  plans.set(scene.sceneIndex, {
346
+ firstSpeaker: speakers[0] ?? null,
347
+ lastSpeaker: speakers.at(-1) ?? null,
81
348
  cutaway: CUTAWAY_KINDS.has(scene.kind),
82
349
  narrated: narratedScene(scene, storyboard),
83
350
  lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
84
351
  cueLineId: typeof scene.cueLineId === "string" ? scene.cueLineId : null,
352
+ cutawayType: typeof scene.cutawayType === "string" ? scene.cutawayType : null,
85
353
  durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
86
354
  });
87
355
  }
@@ -90,12 +358,12 @@ export function scenePlans(storyboard) {
90
358
 
91
359
  /**
92
360
  * Fit a picture to its voice track. The segment always runs as long as the
93
- * voice plus a beat of air, because the voice is never altered. A clip that
94
- * runs longer is trimmed; a clip that runs shorter holds its last frame for the
95
- * difference. A still (no clip) simply runs the voice's length.
361
+ * voice plus a beat of air, to the next whole frame, because the voice is never
362
+ * altered. A clip that runs longer is trimmed; a clip that runs shorter holds
363
+ * its last frame for the difference. A still (no clip) simply runs the voice's length.
96
364
  */
97
365
  export function fitNarration({ clipSeconds, voiceSeconds }) {
98
- const target = voiceSeconds + TAIL_AIR_SEC;
366
+ const target = ceilFrame(voiceSeconds + TAIL_AIR_SEC);
99
367
  if (clipSeconds === null) return { seconds: target, holdSec: 0, trimmedBy: 0 };
100
368
  if (target <= clipSeconds) {
101
369
  return { seconds: target, holdSec: 0, trimmedBy: clipSeconds - target };
@@ -126,18 +394,157 @@ export function cueOffsetOnSpine(spine, counts, cueLineId) {
126
394
  return null;
127
395
  }
128
396
 
129
- export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
397
+ /**
398
+ * Where a segment's spoken cue line ends, in seconds into the segment, or null
399
+ * when the segment does not speak it. A cue line that closes the segment ends
400
+ * where the segment does.
401
+ */
402
+ export function cueEndInSegment(seg, counts, cueLineId) {
403
+ const idx = seg.lineIds.indexOf(cueLineId);
404
+ if (idx === -1) return null;
405
+ if (idx === seg.lineIds.length - 1) return seg.seconds;
406
+ let through = 0;
407
+ let totalWords = 0;
408
+ seg.lineIds.forEach((id, i) => {
409
+ const n = counts.get(id) ?? 0;
410
+ totalWords += n;
411
+ if (i <= idx) through += n;
412
+ });
413
+ if (seg.words && seg.words.length > 0) {
414
+ const last = seg.words[Math.min(Math.max(through - 1, 0), seg.words.length - 1)];
415
+ return Math.min(Math.max(0, last?.e ?? 0), seg.seconds);
416
+ }
417
+ return totalWords > 0 ? (through / totalWords) * seg.seconds : seg.seconds;
418
+ }
419
+
420
+ /**
421
+ * Cut the dead air out of each join between clips that play their own sound: a
422
+ * clip's lead-in silence stacked on the previous clip's tail ran past a second
423
+ * (#2388). Each side comes back to a beat that reads as one conversation, a
424
+ * longer one when someone new answers. Only ever shortens, never into a word,
425
+ * and leaves the ad's first lead-in and last tail alone; a clip whose closing
426
+ * line cues a reaction is not the ad's end, since the beat follows it. Trims
427
+ * land on whole frames so the picture and its sound stay the same length.
428
+ *
429
+ * The beat is measured from the clip's scripted words when the script is heard
430
+ * in order, and a cut stops short of a sound made up before or after the line
431
+ * ("Hmm", "Um"), even at the ad's own edges (#2541). Sounds inside the line stay.
432
+ */
433
+ function trimJoins(spine, reactionCues, scriptLines) {
434
+ spine.forEach((seg, k) => {
435
+ if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
436
+ const prev = spine[k - 1];
437
+ const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
438
+ const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
439
+ const span = scriptedSpan(seg.words, seg.lineIds.map((id) => scriptLines.get(id)?.text));
440
+ const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
441
+ const before = span ? seg.words.slice(0, span.first) : [];
442
+ const after = span ? seg.words.slice(span.last + 1) : [];
443
+ const firstWord = Math.min(...line.map((w) => w.s));
444
+ const lastWord = Math.max(...line.map((w) => w.e));
445
+ let inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
446
+ if (before.length > 0) {
447
+ const clear = ceilFrame(Math.max(...before.map((w) => w.e)) + EXTRA_CLEARANCE_SEC);
448
+ inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
449
+ }
450
+ const endsAd = k === spine.length - 1 && !reactionCues.has(seg.lineIds.at(-1));
451
+ let outSec = endsAd
452
+ ? after.length > 0 ? ceilFrame(lastWord + TAIL_AIR_SEC) : seg.seconds
453
+ : ceilFrame(lastWord + JOIN_TAIL_SEC);
454
+ if (after.length > 0) {
455
+ const clear = floorFrame(Math.min(...after.map((w) => w.s)) - EXTRA_CLEARANCE_SEC);
456
+ outSec = Math.min(outSec, Math.max(clear, ceilFrame(lastWord)));
457
+ }
458
+ outSec = Math.min(seg.seconds, outSec);
459
+ if (outSec <= inSec) return;
460
+ const how = [];
461
+ if (inSec > 0) how.push(`lead-in trimmed ${inSec.toFixed(2)}s`);
462
+ if (outSec < seg.seconds) how.push(`tail trimmed ${(seg.seconds - outSec).toFixed(2)}s`);
463
+ if (how.length === 0) return;
464
+ const kept = seg.words.filter((w) => w.s < outSec && w.e > inSec);
465
+ if (before.some((w) => !kept.includes(w))) how.push("extra sound before the line trimmed");
466
+ if (after.some((w) => !kept.includes(w))) how.push("extra sound after the line trimmed");
467
+ seg.inSec = inSec;
468
+ seg.seconds = outSec - inSec;
469
+ seg.words = kept.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
470
+ seg.note = withHow(seg.note, how);
471
+ });
472
+ }
473
+
474
+ const holdFor = (seg) =>
475
+ seg.source === "clip" ? Math.max(0, seg.seconds - Math.max(0, seg.clipSeconds - seg.inSec)) : 0;
476
+
477
+ /**
478
+ * Put a reaction beat into the spine right after its cue line ends, splitting
479
+ * the segment that speaks it when more lines follow in that segment. Returns
480
+ * the scene that speaks the cue, or null when no segment does.
481
+ */
482
+ function placeReaction(spine, counts, reaction) {
483
+ for (let k = 0; k < spine.length; k++) {
484
+ const seg = spine[k];
485
+ if (seg.reaction) continue;
486
+ const cueEnd = cueEndInSegment(seg, counts, reaction.cueLineId);
487
+ if (cueEnd === null) continue;
488
+ const cut = Math.min(seg.seconds, ceilFrame(cueEnd));
489
+ const beat = {
490
+ sceneIndex: reaction.sceneIndex, source: reaction.source, file: reaction.file,
491
+ clipSeconds: reaction.source === "clip" ? reaction.seconds : null, inSec: 0,
492
+ seconds: Math.min(reaction.seconds, REACTION_MAX_SEC), holdSec: 0,
493
+ audio: reaction.hasAudio ? "clip" : "silence", voice: null, words: null, lineIds: [],
494
+ reaction: { cueLineId: reaction.cueLineId, cueSceneIndex: seg.sceneIndex },
495
+ };
496
+ if (cut >= seg.seconds - 0.05) {
497
+ spine.splice(k + 1, 0, beat);
498
+ return seg.sceneIndex;
499
+ }
500
+ const idx = seg.lineIds.indexOf(reaction.cueLineId);
501
+ const head = {
502
+ ...seg, seconds: cut, lineIds: seg.lineIds.slice(0, idx + 1),
503
+ words: seg.words ? seg.words.filter((w) => w.s < cut) : null,
504
+ note: `${seg.note}, until ${reaction.cueLineId} ends at ${cut.toFixed(2)}s`,
505
+ };
506
+ const tail = {
507
+ ...seg, inSec: seg.inSec + cut, seconds: seg.seconds - cut, lineIds: seg.lineIds.slice(idx + 1),
508
+ words: seg.words
509
+ ? seg.words.filter((w) => w.s >= cut).map((w) => ({ ...w, s: w.s - cut, e: w.e - cut }))
510
+ : null,
511
+ note: `${seg.note}, resumed at ${cut.toFixed(2)}s after reaction ${reaction.sceneIndex}`,
512
+ };
513
+ head.holdSec = holdFor(head);
514
+ tail.holdSec = holdFor(tail);
515
+ spine.splice(k, 1, head, beat, tail);
516
+ return seg.sceneIndex;
517
+ }
518
+ return null;
519
+ }
520
+
521
+ /**
522
+ * The sound a reaction beat carries under the next segment (#2406): the clip's
523
+ * own sound past the beat when it has some (older 4 s pulls), else its last
524
+ * stretch replayed, so a clip exactly as long as its beat still fades out.
525
+ */
526
+ function reactionTail(seg, next) {
527
+ const clipLen = seg.clipSeconds ?? seg.seconds;
528
+ const beatEnd = seg.inSec + seg.seconds;
529
+ const fromSec = Math.max(0, Math.min(beatEnd, clipLen - REACTION_TAIL_SEC));
530
+ const seconds = Math.min(REACTION_TAIL_SEC, next.seconds, clipLen - fromSec);
531
+ return { atSec: next.startSec, fromSec, seconds, gain: REACTION_TAIL_GAIN };
532
+ }
533
+
534
+ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords, readLevels = () => null }) {
130
535
  const plans = scenePlans(storyboard);
131
536
  const counts = lineWordCounts(storyboard);
132
537
  const spine = [];
133
538
  const cutaways = [];
134
539
  const waiting = [];
135
540
  const dropped = [];
136
- let startSec = 0;
137
541
 
138
542
  for (const scene of manifest?.scenes ?? []) {
139
543
  const n = scene.sceneIndex;
140
- const plan = plans.get(n) ?? { cutaway: false, lineIds: [], cueLineId: null, durationSec: null };
544
+ const plan = plans.get(n) ?? {
545
+ cutaway: false, lineIds: [], cueLineId: null, cutawayType: null, durationSec: null,
546
+ firstSpeaker: null, lastSpeaker: null,
547
+ };
141
548
  if (skip.has(n)) {
142
549
  dropped.push({ n, reason: `scene ${n}: skipped by --skip` });
143
550
  continue;
@@ -145,15 +552,17 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
145
552
  const nothing = `nothing to cut with (clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""})`;
146
553
 
147
554
  if (plan.cutaway) {
555
+ const cue = { cueLineId: plan.cueLineId, reaction: plan.cutawayType === "reaction" };
148
556
  if (exists(scene.clip)) {
557
+ const clip = probe(scene.clip);
149
558
  waiting.push({
150
559
  sceneIndex: n, source: "clip", file: scene.clip,
151
- seconds: probe(scene.clip).seconds, cueLineId: plan.cueLineId,
560
+ seconds: clip.seconds, hasAudio: clip.hasAudio, ...cue,
152
561
  });
153
562
  } else if (exists(scene.keyframe)) {
154
563
  waiting.push({
155
564
  sceneIndex: n, source: "still", file: scene.keyframe,
156
- seconds: plan.durationSec ?? 4, cueLineId: plan.cueLineId,
565
+ seconds: plan.durationSec ?? 4, hasAudio: false, ...cue,
157
566
  });
158
567
  } else {
159
568
  dropped.push({ n, reason: `cutaway ${n}: ${nothing}` });
@@ -168,8 +577,8 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
168
577
  if (exists(scene.clip)) {
169
578
  const clip = probe(scene.clip);
170
579
  const base = {
171
- sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds,
172
- startSec, lineIds: plan.lineIds,
580
+ sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds, inSec: 0,
581
+ lineIds: plan.lineIds, firstSpeaker: plan.firstSpeaker, lastSpeaker: plan.lastSpeaker,
173
582
  };
174
583
  if (voice && (narrated || !clip.hasAudio)) {
175
584
  const fit = fitNarration({ clipSeconds: clip.seconds, voiceSeconds: probe(voice).seconds });
@@ -198,7 +607,6 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
198
607
  words: null, note: `${flag}, silent clip`,
199
608
  });
200
609
  }
201
- startSec += spine[spine.length - 1].seconds;
202
610
  continue;
203
611
  }
204
612
  if (exists(scene.keyframe)) {
@@ -206,39 +614,67 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
206
614
  ? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
207
615
  : { seconds: plan.durationSec ?? 4, holdSec: 0 };
208
616
  spine.push({
209
- sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null,
617
+ sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null, inSec: 0,
210
618
  seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, holdSec: 0,
211
- startSec, lineIds: plan.lineIds,
619
+ lineIds: plan.lineIds,
212
620
  // #1689: on a narrated scene the words are the voice track's, and the
213
621
  // voice starts where the segment does, so they need no offset here.
214
622
  words: voice ? readWords(scene.words) : null,
215
623
  note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${fit.seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
216
624
  });
217
- startSec += fit.seconds;
218
625
  continue;
219
626
  }
220
627
  dropped.push({ n, reason: `scene ${n}: ${nothing}` });
221
628
  }
222
629
 
223
- for (const c of waiting) {
630
+ const noCue = (c) =>
631
+ c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
632
+
633
+ // Sentence gaps and then joins are closed first so a reaction beat lands
634
+ // right after its cue line ends in the trimmed clip. Reactions change the
635
+ // spine, so they go in before any overlay is placed on it.
636
+ const scriptLines = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line]));
637
+ const reactionCues = new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId));
638
+ tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels);
639
+ trimJoins(spine, reactionCues, scriptLines);
640
+ for (const c of waiting.filter((w) => w.reaction)) {
641
+ if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
642
+ dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
643
+ }
644
+ }
645
+ let total = 0;
646
+ for (const seg of spine) {
647
+ seg.startSec = total;
648
+ total += seg.seconds;
649
+ }
650
+ spine.forEach((seg, k) => {
651
+ const next = spine[k + 1];
652
+ if (!seg.reaction || seg.audio !== "clip" || !next) return;
653
+ seg.reaction.tail = reactionTail(seg, next);
654
+ });
655
+
656
+ for (const c of waiting.filter((w) => !w.reaction)) {
224
657
  const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
225
658
  if (!cue) {
226
- const why = c.cueLineId
227
- ? `${c.cueLineId} is spoken by no scene in the cut`
228
- : "the storyboard names no cue line";
229
- dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${why}` });
659
+ dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
230
660
  continue;
231
661
  }
232
662
  cutaways.push({ ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex });
233
663
  }
234
664
  cutaways.sort((a, b) => a.startSec - b.startSec);
665
+ const beats = spine.filter((seg) => seg.reaction);
235
666
  for (let i = 0; i < cutaways.length; i++) {
236
667
  const c = cutaways[i];
668
+ const stops = [];
237
669
  const next = cutaways[i + 1];
238
- const end = Math.min(startSec, next ? next.startSec : Infinity);
239
- if (c.startSec + c.seconds > end) {
240
- c.seconds = Math.max(0, end - c.startSec);
241
- c.cutShortBy = next && end === next.startSec ? `cutaway ${next.sceneIndex} starts` : "the ad ends";
670
+ if (next) stops.push({ at: next.startSec, by: `cutaway ${next.sceneIndex} starts` });
671
+ const beat = beats.find((b) => b.startSec >= c.startSec);
672
+ if (beat) stops.push({ at: beat.startSec, by: `reaction ${beat.sceneIndex} starts` });
673
+ stops.push({ at: total, by: "the ad ends" });
674
+ const stop = stops.reduce((a, b) => (b.at < a.at ? b : a));
675
+ if (c.startSec + c.seconds > stop.at) {
676
+ c.seconds = Math.max(0, stop.at - c.startSec);
677
+ c.cutShortBy = stop.by;
242
678
  }
243
679
  }
244
680
 
@@ -262,6 +698,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
262
698
  };
263
699
  const filters = [];
264
700
  const pairs = [];
701
+ const tails = [];
265
702
 
266
703
  for (const seg of timeline.spine) {
267
704
  const i =
@@ -269,16 +706,61 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
269
706
  ? addInput("-i", resolve(seg.file))
270
707
  : addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
271
708
  const fit = [];
272
- if (seg.source === "clip" && seg.clipSeconds !== null && seg.seconds < seg.clipSeconds - 0.01) {
273
- fit.push(`trim=0:${sec(seg.seconds)},setpts=PTS-STARTPTS`);
709
+ const inSec = seg.inSec ?? 0;
710
+ // #2544: sentence gaps come out first, the picture by frame number and the
711
+ // sound by the same times, so everything after works on the cut timeline.
712
+ const cuts = seg.cuts ?? [];
713
+ let heard = `[${i}:a]${stereo},`;
714
+ if (cuts.length > 0) {
715
+ const frames = cuts.map((c) => `between(n\\,${Math.round(c.s * FPS)}\\,${Math.round(c.e * FPS) - 1})`);
716
+ fit.push(`select=not(${frames.join("+")}),setpts=N/FRAME_RATE/TB`);
717
+ const kept = [0, ...cuts.flatMap((c) => [c.s, c.e])];
718
+ const pieces = cuts.length + 1;
719
+ filters.push(`[${i}:a]${stereo},asplit=${pieces}${Array.from({ length: pieces }, (_, p) => `[ap${i}_${p}]`).join("")}`);
720
+ for (let p = 0; p < pieces; p++) {
721
+ const end = p < cuts.length ? `:end=${kept[2 * p + 1].toFixed(6)}` : "";
722
+ filters.push(`[ap${i}_${p}]atrim=start=${kept[2 * p].toFixed(6)}${end},asetpts=PTS-STARTPTS[aq${i}_${p}]`);
723
+ }
724
+ filters.push(
725
+ `${Array.from({ length: pieces }, (_, p) => `[aq${i}_${p}]`).join("")}concat=n=${pieces}:v=0:a=1[ac${i}]`,
726
+ );
727
+ heard = `[ac${i}]`;
728
+ }
729
+ if (seg.source === "clip" && seg.clipSeconds !== null) {
730
+ const shown = seg.seconds - seg.holdSec;
731
+ if (inSec > 0.001 || shown < seg.clipSeconds - 0.01) {
732
+ // By frame number: a time printed to the millisecond can round past the
733
+ // frame it names and drop it.
734
+ const from = Math.round(inSec * FPS);
735
+ fit.push(`trim=start_frame=${from}:end_frame=${Math.round((inSec + shown) * FPS)},setpts=PTS-STARTPTS`);
736
+ }
274
737
  }
275
738
  if (seg.holdSec > 0.01) fit.push(`tpad=stop_mode=clone:stop_duration=${sec(seg.holdSec)}`);
276
739
  filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
740
+ // A segment split around a reaction beat starts partway into its sound too.
741
+ const skipIn = inSec > 0.001 ? `atrim=start=${sec(inSec)},asetpts=PTS-STARTPTS,` : "";
742
+ const fitLength =
743
+ `apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${EDGE_FADE_SEC},` +
744
+ `afade=t=out:st=${sec(seg.seconds - EDGE_FADE_SEC)}:d=${EDGE_FADE_SEC}`;
745
+ const fitSound = `${LOUDNORM},${fitLength}`;
277
746
  if (seg.audio === "narration") {
278
747
  const v = addInput("-i", resolve(seg.voice));
279
- filters.push(`[${v}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
748
+ filters.push(`[${v}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
749
+ } else if (seg.audio === "clip" && seg.reaction?.tail) {
750
+ // The tail is cut from the same loudness-normalized sound as the beat,
751
+ // ducked, faded out, and laid in at the join; the next line's own sound
752
+ // plays at full level over it (#2406).
753
+ const t = seg.reaction.tail;
754
+ filters.push(`${heard}${skipIn}${LOUDNORM},asplit=2[as${i}][at${i}]`);
755
+ filters.push(`[as${i}]${fitLength}[a${i}]`);
756
+ filters.push(
757
+ `[at${i}]atrim=start=${sec(t.fromSec - inSec)}:duration=${sec(t.seconds)},asetpts=PTS-STARTPTS,` +
758
+ `volume=${t.gain},afade=t=in:d=${EDGE_FADE_SEC},afade=t=out:st=0:d=${sec(t.seconds)},` +
759
+ `adelay=delays=${Math.round(t.atSec * 1000)}:all=1[tail${i}]`,
760
+ );
761
+ tails.push(`[tail${i}]`);
280
762
  } else if (seg.audio === "clip") {
281
- filters.push(`[${i}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
763
+ filters.push(`${heard}${skipIn}${fitSound}[a${i}]`);
282
764
  } else {
283
765
  filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
284
766
  }
@@ -308,7 +790,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
308
790
  });
309
791
 
310
792
  filters.push(`${ROOM_TONE}[room]`);
311
- const beds = ["[room]"];
793
+ const beds = ["[room]", ...tails];
312
794
  if (music) {
313
795
  const m = addInput("-stream_loop", "-1", "-i", music);
314
796
  filters.push(`[${m}:a]${stereo},volume=${MUSIC_VOLUME}[bed]`);
@@ -398,6 +880,20 @@ export function inTheCut(timeline) {
398
880
  const rows = [];
399
881
  for (const seg of timeline.spine) {
400
882
  const label = seg.source === "clip" ? seg.file : `still ${seg.file}`;
883
+ if (seg.reaction) {
884
+ const sound = seg.audio === "clip" ? "its own sound" : "silent: the clip has no sound";
885
+ const tail = seg.reaction.tail;
886
+ const fades = tail
887
+ ? `, fading out under the next line at ${tail.atSec.toFixed(2)}s over ${tail.seconds.toFixed(2)}s`
888
+ : "";
889
+ rows.push({
890
+ n: seg.sceneIndex,
891
+ line:
892
+ `reaction ${seg.sceneIndex}: ${label} for ${seg.seconds.toFixed(1)}s after ` +
893
+ `${seg.reaction.cueLineId} in scene ${seg.reaction.cueSceneIndex}, ${sound}${fades}`,
894
+ });
895
+ continue;
896
+ }
401
897
  rows.push({ n: seg.sceneIndex, line: `scene ${seg.sceneIndex}: ${label}${seg.note}` });
402
898
  }
403
899
  for (const c of timeline.cutaways) {
@@ -420,7 +916,7 @@ export function inTheCut(timeline) {
420
916
  export function noWordTimes(timeline) {
421
917
  const rows = timeline.spine
422
918
  .filter((seg) => !seg.words || seg.words.length === 0)
423
- .map((seg) => `scene ${seg.sceneIndex} (no words.json)`);
919
+ .map((seg) => `scene ${seg.sceneIndex} (${seg.reaction ? "reaction" : "no words.json"})`);
424
920
  for (const c of timeline.cutaways) rows.push(`scene ${c.sceneIndex} (cutaway)`);
425
921
  return rows;
426
922
  }
@@ -478,11 +974,14 @@ function main() {
478
974
  };
479
975
  const probe = (file) => {
480
976
  const raw = execFileSync("ffprobe", [
481
- "-v", "error", "-show_entries", "stream=codec_type:format=duration", "-of", "json", here(file),
977
+ "-v", "error", "-show_entries", "stream=codec_type,duration:stream_disposition=attached_pic:format=duration", "-of", "json", here(file),
482
978
  ]).toString();
483
979
  const info = JSON.parse(raw);
980
+ // A clip's container runs a few ms past its picture on the audio's padding;
981
+ // the picture is what the cut shows, so its length is the clip's length.
982
+ const picture = Number((info.streams ?? []).find((s) => s.codec_type === "video" && !s.disposition?.attached_pic)?.duration);
484
983
  return {
485
- seconds: Number(info.format?.duration ?? 0),
984
+ seconds: picture > 0 ? picture : Number(info.format?.duration ?? 0),
486
985
  hasAudio: (info.streams ?? []).some((s) => s.codec_type === "audio"),
487
986
  };
488
987
  };
@@ -501,6 +1000,7 @@ function main() {
501
1000
  const rows = readJson(file);
502
1001
  return Array.isArray(rows) && rows.length > 0 ? rows : null;
503
1002
  },
1003
+ readLevels: (file) => clipLevels(here(file)),
504
1004
  });
505
1005
  if (timeline.spine.length === 0) {
506
1006
  fail(1, `No scene has a clip or a keyframe. Check: exodus video status ${manifest.runId}`);