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

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)) {
@@ -63,7 +63,8 @@ export type ArtifactSubset = {
63
63
  type: "image";
64
64
  imageUrl?: string;
65
65
  storageId?: string;
66
- anchor?: "set-plate" | "product-sheet";
66
+ anchor?: "set-plate" | "product-sheet" | "filled-plate";
67
+ holds?: string[];
67
68
  } | {
68
69
  type: "video";
69
70
  sceneIndex?: number;
@@ -342,6 +343,10 @@ export interface RunVideoChoice {
342
343
  voiceModeLabel: string;
343
344
  }
344
345
  export declare const MUSIC_HEARD_CODE = "music-heard";
346
+ export interface ManifestFilledPlate {
347
+ file: string | null;
348
+ holds: string[];
349
+ }
345
350
  export interface VideoManifest {
346
351
  runId: string;
347
352
  pulledAt: string;
@@ -350,6 +355,7 @@ export interface VideoManifest {
350
355
  reference: string | null;
351
356
  productSheet: string | null;
352
357
  setPlate: string | null;
358
+ filledPlates: ManifestFilledPlate[];
353
359
  music: string | null;
354
360
  musicBed: MusicBedState;
355
361
  musicHeardScenes: number[];
@@ -140,8 +140,9 @@ Options:
140
140
  --voice-path <path> How the voices are made (start)
141
141
  --video-model <id> Which video model films this ad, instead of the kind of
142
142
  ad's own default (start, with --conceit)
143
- --voice <mode> native: the video model speaks the lines. voice-first:
144
- the lines are recorded first (start, with --conceit)
143
+ --voice <mode> voice-first (the default): the lines are recorded
144
+ first. native: the video model speaks the lines
145
+ (start, with --conceit)
145
146
  --music Put a music bed under the ad (start). An ad has no music
146
147
  unless you ask for it here
147
148
  --no-music Leave the music bed off (start), which it already is
@@ -790,9 +791,15 @@ export function planPull(run, items, opts) {
790
791
  let reference = null;
791
792
  let productSheet = null;
792
793
  let setPlate = null;
794
+ const filledByStage = new Map();
793
795
  for (const artifact of outputsOfNodeKind(run, "reference")) {
794
796
  if (artifact.type !== "image" || !artifact.imageUrl)
795
797
  continue;
798
+ if (artifact.anchor === "filled-plate") {
799
+ const holds = artifact.holds ?? [];
800
+ filledByStage.set(JSON.stringify(holds), { holds, url: artifact.imageUrl });
801
+ continue;
802
+ }
796
803
  const stem = artifact.anchor ?? "reference";
797
804
  const download = { file: `${stem}.${extFor(artifact.imageUrl, "image")}`, url: artifact.imageUrl };
798
805
  if (artifact.anchor === "product-sheet")
@@ -802,6 +809,10 @@ export function planPull(run, items, opts) {
802
809
  else if (!artifact.anchor)
803
810
  reference = download;
804
811
  }
812
+ const filledPlates = [...filledByStage.values()].map((plate, i) => ({
813
+ holds: plate.holds,
814
+ download: { file: `filled-plate-${i + 1}.${extFor(plate.url, "image")}`, url: plate.url },
815
+ }));
805
816
  const keyframeByScene = new Map();
806
817
  for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
807
818
  if (artifact.type !== "frames")
@@ -969,6 +980,7 @@ export function planPull(run, items, opts) {
969
980
  ...(reference ? [reference] : []),
970
981
  ...(productSheet ? [productSheet] : []),
971
982
  ...(setPlate ? [setPlate] : []),
983
+ ...filledPlates.map((p) => p.download),
972
984
  ...keyframeByScene.values(),
973
985
  ...[...voiceByScene.values()].map((v) => v.download),
974
986
  ...(narrationDownload ? [narrationDownload] : []),
@@ -987,6 +999,7 @@ export function planPull(run, items, opts) {
987
999
  reference: reference?.file ?? null,
988
1000
  productSheet: productSheet?.file ?? null,
989
1001
  setPlate: setPlate?.file ?? null,
1002
+ filledPlates: filledPlates.map((p) => ({ file: p.download.file, holds: p.holds })),
990
1003
  music: music?.file ?? null,
991
1004
  musicBed: musicBedState(run),
992
1005
  musicHeardScenes: scenes
@@ -1023,6 +1036,10 @@ export function markPullFailure(manifest, failure) {
1023
1036
  manifest.productSheet = null;
1024
1037
  if (manifest.setPlate === failure.file)
1025
1038
  manifest.setPlate = null;
1039
+ for (const plate of manifest.filledPlates) {
1040
+ if (plate.file === failure.file)
1041
+ plate.file = null;
1042
+ }
1026
1043
  if (manifest.music === failure.file)
1027
1044
  manifest.music = null;
1028
1045
  for (const ref of manifest.cast) {
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.24",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",