@aicopycoders/exodus 2026.9.2000-beta.22 → 2026.9.2000-beta.23

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.
@@ -962,8 +962,11 @@ the pieces:
962
962
  reaction beat keep their length.
963
963
  - **It fades the audience reaction in and out.** A reaction beat fades in
964
964
  across any quiet start before its laugh (a generated laugh clip can hum
965
- there) and fades out over its last 0.4 s. Only a reaction clip longer than
966
- its beat carries its own sound on under the next line.
965
+ there). When a line follows, the picture moves on early and the laugh's own
966
+ remaining sound plays on under that line, quieter and fading out: up to
967
+ 0.75 s, and the beat keeps at least 1 s of picture (a 1.5 s clip shows 1 s
968
+ and carries its last 0.5 s). A beat that ends the ad, or has under 0.4 s to
969
+ carry, fades out over its last 0.4 s instead.
967
970
  - **It ignores the continuous master.** It reads only `scene-NN.voice` files;
968
971
  it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
969
972
  master down whole and cuts the pictures to it keeps the read intact.
@@ -71,8 +71,10 @@ export const REACTION_MAX_SEC = 1.5;
71
71
  */
72
72
  export const REACTION_TAIL_SEC = 0.75;
73
73
  export const REACTION_TAIL_GAIN = 0.35;
74
- /** A reaction clip that ends with its beat fades out over this long instead (#2666). */
74
+ /** A reaction clip with too little sound for a tail fades out over this long instead (#2666). */
75
75
  export const REACTION_FADE_OUT_SEC = 0.4;
76
+ /** The least picture a reaction beat keeps when it gives up its end to a tail (#2700). */
77
+ export const REACTION_MIN_BEAT_SEC = 1.0;
76
78
  /**
77
79
  * The air a join keeps around its words (#2388): after one clip's last word, and
78
80
  * before the next clip's first, less when the same speaker carries on than when
@@ -539,17 +541,21 @@ function placeReaction(spine, counts, reaction) {
539
541
  }
540
542
 
541
543
  /**
542
- * The sound a reaction beat carries under the next segment (#2406): the clip's
543
- * own sound past the beat (older 4 s pulls), or null when the clip ends with
544
- * the beat and the beat fades itself out. Replaying the beat's last stretch
545
- * there played its laugh twice, heard as a stutter (#2666).
544
+ * The sound a reaction beat carries under the next segment (#2406), as a J/L
545
+ * cut: the picture moves on early and the clip's sound from where it stops
546
+ * plays under the next line. A clip that ends with its beat gives up the
547
+ * beat's end, down to REACTION_MIN_BEAT_SEC, rather than replaying sound the
548
+ * beat already played, which was heard as a stutter (#2666, #2700). Shortens
549
+ * the beat, so it runs before the spine's start times are set. Null when
550
+ * under 0.4 s of sound would carry over; the beat fades itself out then.
546
551
  */
547
- function reactionTail(seg, next) {
552
+ function carveReactionTail(seg, next) {
548
553
  const clipLen = seg.clipSeconds ?? seg.seconds;
549
- const fromSec = seg.inSec + seg.seconds;
550
- if (clipLen - fromSec < REACTION_FADE_OUT_SEC) return null;
551
- const seconds = Math.min(REACTION_TAIL_SEC, next.seconds, clipLen - fromSec);
552
- return { atSec: next.startSec, fromSec, seconds, gain: REACTION_TAIL_GAIN };
554
+ const overlap =
555
+ Math.round(Math.min(REACTION_TAIL_SEC, clipLen - seg.inSec - REACTION_MIN_BEAT_SEC, next.seconds) * 1000) / 1000;
556
+ if (overlap < REACTION_FADE_OUT_SEC) return null;
557
+ seg.seconds = Math.min(seg.seconds, Math.round((clipLen - seg.inSec - overlap) * 1000) / 1000);
558
+ return { fromSec: seg.inSec + seg.seconds, seconds: overlap, gain: REACTION_TAIL_GAIN };
553
559
  }
554
560
 
555
561
  /**
@@ -673,17 +679,20 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
673
679
  dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
674
680
  }
675
681
  }
682
+ spine.forEach((seg, k) => {
683
+ if (!seg.reaction || seg.audio !== "clip") return;
684
+ const tail = spine[k + 1] ? carveReactionTail(seg, spine[k + 1]) : null;
685
+ if (tail) seg.reaction.tail = tail;
686
+ else seg.fadeOutSec = Math.min(REACTION_FADE_OUT_SEC, seg.seconds);
687
+ seg.fadeInSec = quietStart(readLevels(seg.file), seg.seconds);
688
+ });
676
689
  let total = 0;
677
690
  for (const seg of spine) {
678
691
  seg.startSec = total;
679
692
  total += seg.seconds;
680
693
  }
681
694
  spine.forEach((seg, k) => {
682
- if (!seg.reaction || seg.audio !== "clip") return;
683
- seg.fadeInSec = quietStart(readLevels(seg.file), seg.seconds);
684
- const tail = spine[k + 1] ? reactionTail(seg, spine[k + 1]) : null;
685
- if (tail) seg.reaction.tail = tail;
686
- else seg.fadeOutSec = Math.min(REACTION_FADE_OUT_SEC, seg.seconds);
695
+ if (seg.reaction?.tail) seg.reaction.tail.atSec = spine[k + 1].startSec;
687
696
  });
688
697
 
689
698
  for (const c of waiting.filter((w) => !w.reaction)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.22",
3
+ "version": "2026.9.2000-beta.23",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",