@aicopycoders/exodus 2026.9.2000-beta.15 → 2026.9.2000-beta.16

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.
@@ -4,7 +4,8 @@
4
4
  // spine at the moment its cued line is spoken, a room-tone bed and the music
5
5
  // bed go underneath, and one MP4 comes out ready to upload. A reaction
6
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.
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.
8
9
  //
9
10
  // node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
10
11
  //
@@ -59,6 +60,13 @@ const ROOM_TONE =
59
60
  const MUSIC_VOLUME = 0.18;
60
61
  /** The longest a reaction beat holds the spine (#2357); older pulls carry 4 s reaction clips. */
61
62
  export const REACTION_MAX_SEC = 1.5;
63
+ /**
64
+ * A reaction's laugh carries into the next line instead of stopping dead at
65
+ * the join (#2406): it starts well under the speaker, at this share of its own
66
+ * level (about -9 dB), and fades to silence over this long.
67
+ */
68
+ export const REACTION_TAIL_SEC = 0.75;
69
+ export const REACTION_TAIL_GAIN = 0.35;
62
70
  /**
63
71
  * The air a join keeps around its words (#2388): after one clip's last word, and
64
72
  * before the next clip's first, less when the same speaker carries on than when
@@ -262,6 +270,19 @@ function placeReaction(spine, counts, reaction) {
262
270
  return null;
263
271
  }
264
272
 
273
+ /**
274
+ * The sound a reaction beat carries under the next segment (#2406): the clip's
275
+ * own sound past the beat when it has some (older 4 s pulls), else its last
276
+ * stretch replayed, so a clip exactly as long as its beat still fades out.
277
+ */
278
+ function reactionTail(seg, next) {
279
+ const clipLen = seg.clipSeconds ?? seg.seconds;
280
+ const beatEnd = seg.inSec + seg.seconds;
281
+ const fromSec = Math.max(0, Math.min(beatEnd, clipLen - REACTION_TAIL_SEC));
282
+ const seconds = Math.min(REACTION_TAIL_SEC, next.seconds, clipLen - fromSec);
283
+ return { atSec: next.startSec, fromSec, seconds, gain: REACTION_TAIL_GAIN };
284
+ }
285
+
265
286
  export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
266
287
  const plans = scenePlans(storyboard);
267
288
  const counts = lineWordCounts(storyboard);
@@ -375,6 +396,11 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
375
396
  seg.startSec = total;
376
397
  total += seg.seconds;
377
398
  }
399
+ spine.forEach((seg, k) => {
400
+ const next = spine[k + 1];
401
+ if (!seg.reaction || seg.audio !== "clip" || !next) return;
402
+ seg.reaction.tail = reactionTail(seg, next);
403
+ });
378
404
 
379
405
  for (const c of waiting.filter((w) => !w.reaction)) {
380
406
  const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
@@ -421,6 +447,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
421
447
  };
422
448
  const filters = [];
423
449
  const pairs = [];
450
+ const tails = [];
424
451
 
425
452
  for (const seg of timeline.spine) {
426
453
  const i =
@@ -442,12 +469,26 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
442
469
  filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
443
470
  // A segment split around a reaction beat starts partway into its sound too.
444
471
  const skipIn = inSec > 0.001 ? `atrim=start=${sec(inSec)},asetpts=PTS-STARTPTS,` : "";
445
- const fitSound =
446
- `${LOUDNORM},apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${EDGE_FADE_SEC},` +
472
+ const fitLength =
473
+ `apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${EDGE_FADE_SEC},` +
447
474
  `afade=t=out:st=${sec(seg.seconds - EDGE_FADE_SEC)}:d=${EDGE_FADE_SEC}`;
475
+ const fitSound = `${LOUDNORM},${fitLength}`;
448
476
  if (seg.audio === "narration") {
449
477
  const v = addInput("-i", resolve(seg.voice));
450
478
  filters.push(`[${v}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
479
+ } else if (seg.audio === "clip" && seg.reaction?.tail) {
480
+ // The tail is cut from the same loudness-normalized sound as the beat,
481
+ // ducked, faded out, and laid in at the join; the next line's own sound
482
+ // plays at full level over it (#2406).
483
+ const t = seg.reaction.tail;
484
+ filters.push(`[${i}:a]${stereo},${skipIn}${LOUDNORM},asplit=2[as${i}][at${i}]`);
485
+ filters.push(`[as${i}]${fitLength}[a${i}]`);
486
+ filters.push(
487
+ `[at${i}]atrim=start=${sec(t.fromSec - inSec)}:duration=${sec(t.seconds)},asetpts=PTS-STARTPTS,` +
488
+ `volume=${t.gain},afade=t=in:d=${EDGE_FADE_SEC},afade=t=out:st=0:d=${sec(t.seconds)},` +
489
+ `adelay=delays=${Math.round(t.atSec * 1000)}:all=1[tail${i}]`,
490
+ );
491
+ tails.push(`[tail${i}]`);
451
492
  } else if (seg.audio === "clip") {
452
493
  filters.push(`[${i}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
453
494
  } else {
@@ -479,7 +520,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
479
520
  });
480
521
 
481
522
  filters.push(`${ROOM_TONE}[room]`);
482
- const beds = ["[room]"];
523
+ const beds = ["[room]", ...tails];
483
524
  if (music) {
484
525
  const m = addInput("-stream_loop", "-1", "-i", music);
485
526
  filters.push(`[${m}:a]${stereo},volume=${MUSIC_VOLUME}[bed]`);
@@ -571,11 +612,15 @@ export function inTheCut(timeline) {
571
612
  const label = seg.source === "clip" ? seg.file : `still ${seg.file}`;
572
613
  if (seg.reaction) {
573
614
  const sound = seg.audio === "clip" ? "its own sound" : "silent: the clip has no sound";
615
+ const tail = seg.reaction.tail;
616
+ const fades = tail
617
+ ? `, fading out under the next line at ${tail.atSec.toFixed(2)}s over ${tail.seconds.toFixed(2)}s`
618
+ : "";
574
619
  rows.push({
575
620
  n: seg.sceneIndex,
576
621
  line:
577
622
  `reaction ${seg.sceneIndex}: ${label} for ${seg.seconds.toFixed(1)}s after ` +
578
- `${seg.reaction.cueLineId} in scene ${seg.reaction.cueSceneIndex}, ${sound}`,
623
+ `${seg.reaction.cueLineId} in scene ${seg.reaction.cueSceneIndex}, ${sound}${fades}`,
579
624
  });
580
625
  continue;
581
626
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.15",
3
+ "version": "2026.9.2000-beta.16",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",