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

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.
@@ -631,9 +631,12 @@ depends on the scene; see "Which scenes carry their voice where" below.
631
631
  terms. `script[]` is the ad's spoken words in order — `{lineId, speakerId, text}`,
632
632
  with `speakerId: null` meaning narrator VO. Each scene carries `sceneIndex`,
633
633
  `durationSec`, `dialogue[]`, `voText`, `notes`, and `lineIds` (the script lines
634
- that scene renders). A scene with `kind: "cutaway"` carries `cueLineId` instead:
635
- it is a picture-only takeover of the frame while the spoken track underneath
636
- keeps running, so it adds no audio and takes no place in the running order. The
634
+ that scene renders). A scene with `kind: "cutaway"` carries `cueLineId` and a
635
+ `cutawayType` instead. Most cutaways are a picture-only takeover of the frame
636
+ while the spoken track underneath keeps running, so they add no audio and take
637
+ no place in the running order. A `cutawayType: "reaction"` cutaway is the room
638
+ reacting out loud to its cue line: it is its own short beat after that line,
639
+ heard with its own sound while the speaker is silent. The
637
640
  envelope also carries `overlays[]` (each with a `cueLineId` and a `placement`)
638
641
  and `audio` (`roomTone`, `music`, `sfxCues[]`) — the planner's notes on what
639
642
  should sit on top. The image and motion prompts (`framePrompt`, `videoPrompt`,
@@ -780,7 +783,7 @@ them. If the script's words are wrong, that is a new run.
780
783
 
781
784
  **`--voice-first` fixes a clip that said its line wrong** (#2299). It is for a
782
785
  clip whose findings include `speech-mismatch`, `speech-repeat`,
783
- `speech-after-line` or `speech-long-pause`. The scene's line is recorded in each
786
+ `speech-after-line`, `speech-extra-words` or `speech-long-pause`. The scene's line is recorded in each
784
787
  speaker's pinned ElevenLabs voice, and MiniMax (`minimax-h3/reference-to-video`)
785
788
  makes the clip to match the recording. Only that clip changes; the word check
786
789
  and the other checks still run on it. It needs an ElevenLabs key and a voice for
@@ -898,6 +901,13 @@ Read these out of `storyboard.json` before you build anything, in any tool:
898
901
  running order. To find the moment, locate the A-roll scene whose `lineIds`
899
902
  contain the cue line, then read the start time of the first word of that line
900
903
  out of that scene's `words.json`.
904
+ - **Reactions.** A cutaway with `cutawayType: "reaction"` is the exception. Do
905
+ not lay it over the speech. Put it into the running order as its own beat
906
+ right after its cue line ends, play its own sound (the audience laughing),
907
+ keep it to about 1.5 s, then carry on with the speaker. Nobody talks during
908
+ it. When more lines follow the cue line in the same scene, cut that scene
909
+ where the cue line's last word ends in its `words.json` and put the reaction
910
+ between the two halves.
901
911
  - **Overlays and SFX.** `overlays[]` and `audio.sfxCues[]` are cued the same
902
912
  way, by `cueLineId`. Nothing renders them for you.
903
913
  - **Captions.** Nothing is burned into any clip. Captions belong in post; the
@@ -914,12 +924,20 @@ the pieces:
914
924
  holds its last frame for the difference. The narration is never sped up,
915
925
  slowed down or cut short, so every scene plays at the speed it was recorded,
916
926
  and the word times are handed on as they were measured.
927
+ - **It closes the dead air at the joins.** A clip that plays its own dialogue
928
+ keeps about 0.15 s after its last word and about 0.1 s before its first (0.25 s
929
+ when a different speaker answers), read from its `words.json`, so one clip
930
+ runs into the next like one conversation. It only ever shortens a clip's
931
+ silence and never cuts into a word. The ad's first lead-in and last tail, a
932
+ narrated scene, a still and a reaction beat are left as they are.
917
933
  - **It ignores the continuous master.** It reads only `scene-NN.voice` files;
918
934
  it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
919
935
  master down whole and cuts the pictures to it keeps the read intact.
920
- - Otherwise: A-roll in manifest order as the spine, each cutaway laid over the
921
- spine at its cued word (falling back to a proportional estimate when the scene
922
- has no word times, and saying which method it used), a narrated scene's voice
936
+ - Otherwise: A-roll in manifest order as the spine, each reaction put in as its
937
+ own beat of at most 1.5 s right after its cue line with its own sound, each
938
+ other cutaway laid over the spine at its cued word (falling back to a
939
+ proportional estimate when the scene has no word times, and saying which
940
+ method it used), a narrated scene's voice
923
941
  replacing whatever the clip recorded, a dialogue scene keeping its performed
924
942
  line, a clip-less scene becoming a still, everything normalized to 1080×1920
925
943
  at 24 fps and −16 LUFS per segment, a generated room-tone bed under the whole
@@ -931,14 +949,15 @@ the pieces:
931
949
  ad with its start and end) and `cut.srt` (the same words grouped into subtitle
932
950
  lines), named from the `--out` basename. Those times are on the finished ad's
933
951
  timeline, each scene's own word times offset by where that scene starts in the
934
- cut and otherwise unaltered. Scenes with no word times leave
952
+ cut, less any lead-in trimmed at the join, and otherwise unaltered. Scenes with no word times leave
935
953
  gaps and are named in the report; when no scene delivered any, neither file is
936
954
  written. These sidecars are the script's own output — another tool will not
937
955
  produce them.
938
956
 
939
957
  Before uploading anything, from any tool, check what you made:
940
958
  `ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 cut.mp4`
941
- should be close to the sum of the scene durations you kept. A cut that is a few
959
+ should be close to the sum of the scene durations you kept (the bundled
960
+ script's cut runs somewhat shorter, by the silence it trimmed at the joins). A cut that is a few
942
961
  seconds long when five clips went in means a filter dropped inputs; do not
943
962
  upload it.
944
963
 
@@ -2,7 +2,9 @@
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.
6
8
  //
7
9
  // node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
8
10
  //
@@ -11,9 +13,11 @@
11
13
  // recorded: the voice replaces the clip's audio, and the picture is fitted to
12
14
  // the voice, trimmed when the clip runs longer and held on its last frame when
13
15
  // 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.
16
+ // keeps the audio it performed; at each join its silence before the first word
17
+ // and after the last is cut back to a short beat, read from its word times. A
18
+ // scene with no clip but a keyframe becomes a still for the length of its voice
19
+ // track, else the storyboard's planned duration. Every segment is
20
+ // loudness-normalized to -16 LUFS before the join.
17
21
  // --skip drops scenes.
18
22
  //
19
23
  // After the render it writes the whole ad's word times beside the MP4, as
@@ -30,15 +34,39 @@ const FPS = 24;
30
34
  const norm = (label) =>
31
35
  `[${label}]scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
32
36
  `pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS},format=yuv420p`;
37
+ /**
38
+ * Edges the cut chooses (join trims, reaction splits, narration lengths) land
39
+ * on whole frames, so the picture runs as long as its sound there and the
40
+ * timeline's times are the cut's times.
41
+ */
42
+ const floorFrame = (t) => Math.floor(t * FPS + 1e-6) / FPS;
43
+ const ceilFrame = (t) => Math.ceil(t * FPS - 1e-6) / FPS;
33
44
  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";
45
+ /**
46
+ * Per-segment loudness, the level every clip was normalized to before the join.
47
+ * loudnorm stamps its output about 0.1 s late (its look-ahead), so a later
48
+ * atrim by time kept that much less sound than picture and every join pulled
49
+ * the sound further ahead (#2387). Re-stamping by sample count undoes it.
50
+ */
51
+ const LOUDNORM = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000,asetpts=N/SR/TB";
52
+ /** A fade this short at each edge of a segment's sound stops a hard cut in the room tone clicking. */
53
+ const EDGE_FADE_SEC = 0.015;
36
54
  /** Air after the last spoken word of a narrated scene, so the line lands. */
37
55
  export const TAIL_AIR_SEC = 0.8;
38
56
  /** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
39
57
  const ROOM_TONE =
40
58
  "anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
41
59
  const MUSIC_VOLUME = 0.18;
60
+ /** The longest a reaction beat holds the spine (#2357); older pulls carry 4 s reaction clips. */
61
+ export const REACTION_MAX_SEC = 1.5;
62
+ /**
63
+ * The air a join keeps around its words (#2388): after one clip's last word, and
64
+ * before the next clip's first, less when the same speaker carries on than when
65
+ * someone new answers.
66
+ */
67
+ export const JOIN_TAIL_SEC = 0.15;
68
+ export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
69
+ export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
42
70
 
43
71
  /** The word rule the planner sized every line with (scout models.ts wordCount). */
44
72
  export function wordCount(text) {
@@ -77,11 +105,17 @@ export function scenePlans(storyboard) {
77
105
  const plans = new Map();
78
106
  for (const scene of storyboard?.scenes ?? []) {
79
107
  if (typeof scene?.sceneIndex !== "number") continue;
108
+ const speakers = (Array.isArray(scene.dialogue) ? scene.dialogue : [])
109
+ .filter((turn) => hasText(turn?.line) || hasText(turn?.text))
110
+ .map((turn) => (hasText(turn.speaker) ? turn.speaker.trim() : null));
80
111
  plans.set(scene.sceneIndex, {
112
+ firstSpeaker: speakers[0] ?? null,
113
+ lastSpeaker: speakers.at(-1) ?? null,
81
114
  cutaway: CUTAWAY_KINDS.has(scene.kind),
82
115
  narrated: narratedScene(scene, storyboard),
83
116
  lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
84
117
  cueLineId: typeof scene.cueLineId === "string" ? scene.cueLineId : null,
118
+ cutawayType: typeof scene.cutawayType === "string" ? scene.cutawayType : null,
85
119
  durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
86
120
  });
87
121
  }
@@ -90,12 +124,12 @@ export function scenePlans(storyboard) {
90
124
 
91
125
  /**
92
126
  * 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.
127
+ * voice plus a beat of air, to the next whole frame, because the voice is never
128
+ * altered. A clip that runs longer is trimmed; a clip that runs shorter holds
129
+ * its last frame for the difference. A still (no clip) simply runs the voice's length.
96
130
  */
97
131
  export function fitNarration({ clipSeconds, voiceSeconds }) {
98
- const target = voiceSeconds + TAIL_AIR_SEC;
132
+ const target = ceilFrame(voiceSeconds + TAIL_AIR_SEC);
99
133
  if (clipSeconds === null) return { seconds: target, holdSec: 0, trimmedBy: 0 };
100
134
  if (target <= clipSeconds) {
101
135
  return { seconds: target, holdSec: 0, trimmedBy: clipSeconds - target };
@@ -126,6 +160,108 @@ export function cueOffsetOnSpine(spine, counts, cueLineId) {
126
160
  return null;
127
161
  }
128
162
 
163
+ /**
164
+ * Where a segment's spoken cue line ends, in seconds into the segment, or null
165
+ * when the segment does not speak it. A cue line that closes the segment ends
166
+ * where the segment does.
167
+ */
168
+ export function cueEndInSegment(seg, counts, cueLineId) {
169
+ const idx = seg.lineIds.indexOf(cueLineId);
170
+ if (idx === -1) return null;
171
+ if (idx === seg.lineIds.length - 1) return seg.seconds;
172
+ let through = 0;
173
+ let totalWords = 0;
174
+ seg.lineIds.forEach((id, i) => {
175
+ const n = counts.get(id) ?? 0;
176
+ totalWords += n;
177
+ if (i <= idx) through += n;
178
+ });
179
+ if (seg.words && seg.words.length > 0) {
180
+ const last = seg.words[Math.min(Math.max(through - 1, 0), seg.words.length - 1)];
181
+ return Math.min(Math.max(0, last?.e ?? 0), seg.seconds);
182
+ }
183
+ return totalWords > 0 ? (through / totalWords) * seg.seconds : seg.seconds;
184
+ }
185
+
186
+ /**
187
+ * Cut the dead air out of each join between clips that play their own sound: a
188
+ * clip's lead-in silence stacked on the previous clip's tail ran past a second
189
+ * (#2388). Each side comes back to a beat that reads as one conversation, a
190
+ * longer one when someone new answers. Only ever shortens, never into a word,
191
+ * and leaves the ad's first lead-in and last tail alone; a clip whose closing
192
+ * line cues a reaction is not the ad's end, since the beat follows it. Trims
193
+ * land on whole frames so the picture and its sound stay the same length.
194
+ */
195
+ function trimJoins(spine, reactionCues) {
196
+ spine.forEach((seg, k) => {
197
+ if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
198
+ const prev = spine[k - 1];
199
+ const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
200
+ const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
201
+ const firstWord = Math.min(...seg.words.map((w) => w.s));
202
+ const lastWord = Math.max(...seg.words.map((w) => w.e));
203
+ const inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
204
+ const endsAd = k === spine.length - 1 && !reactionCues.has(seg.lineIds.at(-1));
205
+ const outSec = endsAd ? seg.seconds : Math.min(seg.seconds, ceilFrame(lastWord + JOIN_TAIL_SEC));
206
+ if (outSec <= inSec) return;
207
+ const how = [];
208
+ if (inSec > 0) how.push(`lead-in trimmed ${inSec.toFixed(2)}s`);
209
+ if (outSec < seg.seconds) how.push(`tail trimmed ${(seg.seconds - outSec).toFixed(2)}s`);
210
+ if (how.length === 0) return;
211
+ seg.inSec = inSec;
212
+ seg.seconds = outSec - inSec;
213
+ seg.words = seg.words.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
214
+ seg.note = `${seg.note} (${how.join(", ")})`;
215
+ });
216
+ }
217
+
218
+ const holdFor = (seg) =>
219
+ seg.source === "clip" ? Math.max(0, seg.seconds - Math.max(0, seg.clipSeconds - seg.inSec)) : 0;
220
+
221
+ /**
222
+ * Put a reaction beat into the spine right after its cue line ends, splitting
223
+ * the segment that speaks it when more lines follow in that segment. Returns
224
+ * the scene that speaks the cue, or null when no segment does.
225
+ */
226
+ function placeReaction(spine, counts, reaction) {
227
+ for (let k = 0; k < spine.length; k++) {
228
+ const seg = spine[k];
229
+ if (seg.reaction) continue;
230
+ const cueEnd = cueEndInSegment(seg, counts, reaction.cueLineId);
231
+ if (cueEnd === null) continue;
232
+ const cut = Math.min(seg.seconds, ceilFrame(cueEnd));
233
+ const beat = {
234
+ sceneIndex: reaction.sceneIndex, source: reaction.source, file: reaction.file,
235
+ clipSeconds: reaction.source === "clip" ? reaction.seconds : null, inSec: 0,
236
+ seconds: Math.min(reaction.seconds, REACTION_MAX_SEC), holdSec: 0,
237
+ audio: reaction.hasAudio ? "clip" : "silence", voice: null, words: null, lineIds: [],
238
+ reaction: { cueLineId: reaction.cueLineId, cueSceneIndex: seg.sceneIndex },
239
+ };
240
+ if (cut >= seg.seconds - 0.05) {
241
+ spine.splice(k + 1, 0, beat);
242
+ return seg.sceneIndex;
243
+ }
244
+ const idx = seg.lineIds.indexOf(reaction.cueLineId);
245
+ const head = {
246
+ ...seg, seconds: cut, lineIds: seg.lineIds.slice(0, idx + 1),
247
+ words: seg.words ? seg.words.filter((w) => w.s < cut) : null,
248
+ note: `${seg.note}, until ${reaction.cueLineId} ends at ${cut.toFixed(2)}s`,
249
+ };
250
+ const tail = {
251
+ ...seg, inSec: seg.inSec + cut, seconds: seg.seconds - cut, lineIds: seg.lineIds.slice(idx + 1),
252
+ words: seg.words
253
+ ? seg.words.filter((w) => w.s >= cut).map((w) => ({ ...w, s: w.s - cut, e: w.e - cut }))
254
+ : null,
255
+ note: `${seg.note}, resumed at ${cut.toFixed(2)}s after reaction ${reaction.sceneIndex}`,
256
+ };
257
+ head.holdSec = holdFor(head);
258
+ tail.holdSec = holdFor(tail);
259
+ spine.splice(k, 1, head, beat, tail);
260
+ return seg.sceneIndex;
261
+ }
262
+ return null;
263
+ }
264
+
129
265
  export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
130
266
  const plans = scenePlans(storyboard);
131
267
  const counts = lineWordCounts(storyboard);
@@ -133,11 +269,13 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
133
269
  const cutaways = [];
134
270
  const waiting = [];
135
271
  const dropped = [];
136
- let startSec = 0;
137
272
 
138
273
  for (const scene of manifest?.scenes ?? []) {
139
274
  const n = scene.sceneIndex;
140
- const plan = plans.get(n) ?? { cutaway: false, lineIds: [], cueLineId: null, durationSec: null };
275
+ const plan = plans.get(n) ?? {
276
+ cutaway: false, lineIds: [], cueLineId: null, cutawayType: null, durationSec: null,
277
+ firstSpeaker: null, lastSpeaker: null,
278
+ };
141
279
  if (skip.has(n)) {
142
280
  dropped.push({ n, reason: `scene ${n}: skipped by --skip` });
143
281
  continue;
@@ -145,15 +283,17 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
145
283
  const nothing = `nothing to cut with (clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""})`;
146
284
 
147
285
  if (plan.cutaway) {
286
+ const cue = { cueLineId: plan.cueLineId, reaction: plan.cutawayType === "reaction" };
148
287
  if (exists(scene.clip)) {
288
+ const clip = probe(scene.clip);
149
289
  waiting.push({
150
290
  sceneIndex: n, source: "clip", file: scene.clip,
151
- seconds: probe(scene.clip).seconds, cueLineId: plan.cueLineId,
291
+ seconds: clip.seconds, hasAudio: clip.hasAudio, ...cue,
152
292
  });
153
293
  } else if (exists(scene.keyframe)) {
154
294
  waiting.push({
155
295
  sceneIndex: n, source: "still", file: scene.keyframe,
156
- seconds: plan.durationSec ?? 4, cueLineId: plan.cueLineId,
296
+ seconds: plan.durationSec ?? 4, hasAudio: false, ...cue,
157
297
  });
158
298
  } else {
159
299
  dropped.push({ n, reason: `cutaway ${n}: ${nothing}` });
@@ -168,8 +308,8 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
168
308
  if (exists(scene.clip)) {
169
309
  const clip = probe(scene.clip);
170
310
  const base = {
171
- sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds,
172
- startSec, lineIds: plan.lineIds,
311
+ sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds, inSec: 0,
312
+ lineIds: plan.lineIds, firstSpeaker: plan.firstSpeaker, lastSpeaker: plan.lastSpeaker,
173
313
  };
174
314
  if (voice && (narrated || !clip.hasAudio)) {
175
315
  const fit = fitNarration({ clipSeconds: clip.seconds, voiceSeconds: probe(voice).seconds });
@@ -198,7 +338,6 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
198
338
  words: null, note: `${flag}, silent clip`,
199
339
  });
200
340
  }
201
- startSec += spine[spine.length - 1].seconds;
202
341
  continue;
203
342
  }
204
343
  if (exists(scene.keyframe)) {
@@ -206,39 +345,59 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
206
345
  ? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
207
346
  : { seconds: plan.durationSec ?? 4, holdSec: 0 };
208
347
  spine.push({
209
- sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null,
348
+ sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null, inSec: 0,
210
349
  seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, holdSec: 0,
211
- startSec, lineIds: plan.lineIds,
350
+ lineIds: plan.lineIds,
212
351
  // #1689: on a narrated scene the words are the voice track's, and the
213
352
  // voice starts where the segment does, so they need no offset here.
214
353
  words: voice ? readWords(scene.words) : null,
215
354
  note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${fit.seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
216
355
  });
217
- startSec += fit.seconds;
218
356
  continue;
219
357
  }
220
358
  dropped.push({ n, reason: `scene ${n}: ${nothing}` });
221
359
  }
222
360
 
223
- for (const c of waiting) {
361
+ const noCue = (c) =>
362
+ c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
363
+
364
+ // Joins are closed first so a reaction beat lands right after its cue line
365
+ // ends in the trimmed clip. Reactions change the spine, so they go in before
366
+ // any overlay is placed on it.
367
+ trimJoins(spine, new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId)));
368
+ for (const c of waiting.filter((w) => w.reaction)) {
369
+ if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
370
+ dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
371
+ }
372
+ }
373
+ let total = 0;
374
+ for (const seg of spine) {
375
+ seg.startSec = total;
376
+ total += seg.seconds;
377
+ }
378
+
379
+ for (const c of waiting.filter((w) => !w.reaction)) {
224
380
  const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
225
381
  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}` });
382
+ dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
230
383
  continue;
231
384
  }
232
385
  cutaways.push({ ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex });
233
386
  }
234
387
  cutaways.sort((a, b) => a.startSec - b.startSec);
388
+ const beats = spine.filter((seg) => seg.reaction);
235
389
  for (let i = 0; i < cutaways.length; i++) {
236
390
  const c = cutaways[i];
391
+ const stops = [];
237
392
  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";
393
+ if (next) stops.push({ at: next.startSec, by: `cutaway ${next.sceneIndex} starts` });
394
+ const beat = beats.find((b) => b.startSec >= c.startSec);
395
+ if (beat) stops.push({ at: beat.startSec, by: `reaction ${beat.sceneIndex} starts` });
396
+ stops.push({ at: total, by: "the ad ends" });
397
+ const stop = stops.reduce((a, b) => (b.at < a.at ? b : a));
398
+ if (c.startSec + c.seconds > stop.at) {
399
+ c.seconds = Math.max(0, stop.at - c.startSec);
400
+ c.cutShortBy = stop.by;
242
401
  }
243
402
  }
244
403
 
@@ -269,16 +428,28 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
269
428
  ? addInput("-i", resolve(seg.file))
270
429
  : addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
271
430
  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`);
431
+ const inSec = seg.inSec ?? 0;
432
+ if (seg.source === "clip" && seg.clipSeconds !== null) {
433
+ const shown = seg.seconds - seg.holdSec;
434
+ if (inSec > 0.001 || shown < seg.clipSeconds - 0.01) {
435
+ // By frame number: a time printed to the millisecond can round past the
436
+ // frame it names and drop it.
437
+ const from = Math.round(inSec * FPS);
438
+ fit.push(`trim=start_frame=${from}:end_frame=${Math.round((inSec + shown) * FPS)},setpts=PTS-STARTPTS`);
439
+ }
274
440
  }
275
441
  if (seg.holdSec > 0.01) fit.push(`tpad=stop_mode=clone:stop_duration=${sec(seg.holdSec)}`);
276
442
  filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
443
+ // A segment split around a reaction beat starts partway into its sound too.
444
+ 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},` +
447
+ `afade=t=out:st=${sec(seg.seconds - EDGE_FADE_SEC)}:d=${EDGE_FADE_SEC}`;
277
448
  if (seg.audio === "narration") {
278
449
  const v = addInput("-i", resolve(seg.voice));
279
- filters.push(`[${v}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
450
+ filters.push(`[${v}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
280
451
  } else if (seg.audio === "clip") {
281
- filters.push(`[${i}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
452
+ filters.push(`[${i}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
282
453
  } else {
283
454
  filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
284
455
  }
@@ -398,6 +569,16 @@ export function inTheCut(timeline) {
398
569
  const rows = [];
399
570
  for (const seg of timeline.spine) {
400
571
  const label = seg.source === "clip" ? seg.file : `still ${seg.file}`;
572
+ if (seg.reaction) {
573
+ const sound = seg.audio === "clip" ? "its own sound" : "silent: the clip has no sound";
574
+ rows.push({
575
+ n: seg.sceneIndex,
576
+ line:
577
+ `reaction ${seg.sceneIndex}: ${label} for ${seg.seconds.toFixed(1)}s after ` +
578
+ `${seg.reaction.cueLineId} in scene ${seg.reaction.cueSceneIndex}, ${sound}`,
579
+ });
580
+ continue;
581
+ }
401
582
  rows.push({ n: seg.sceneIndex, line: `scene ${seg.sceneIndex}: ${label}${seg.note}` });
402
583
  }
403
584
  for (const c of timeline.cutaways) {
@@ -420,7 +601,7 @@ export function inTheCut(timeline) {
420
601
  export function noWordTimes(timeline) {
421
602
  const rows = timeline.spine
422
603
  .filter((seg) => !seg.words || seg.words.length === 0)
423
- .map((seg) => `scene ${seg.sceneIndex} (no words.json)`);
604
+ .map((seg) => `scene ${seg.sceneIndex} (${seg.reaction ? "reaction" : "no words.json"})`);
424
605
  for (const c of timeline.cutaways) rows.push(`scene ${c.sceneIndex} (cutaway)`);
425
606
  return rows;
426
607
  }
@@ -478,11 +659,14 @@ function main() {
478
659
  };
479
660
  const probe = (file) => {
480
661
  const raw = execFileSync("ffprobe", [
481
- "-v", "error", "-show_entries", "stream=codec_type:format=duration", "-of", "json", here(file),
662
+ "-v", "error", "-show_entries", "stream=codec_type,duration:stream_disposition=attached_pic:format=duration", "-of", "json", here(file),
482
663
  ]).toString();
483
664
  const info = JSON.parse(raw);
665
+ // A clip's container runs a few ms past its picture on the audio's padding;
666
+ // the picture is what the cut shows, so its length is the clip's length.
667
+ const picture = Number((info.streams ?? []).find((s) => s.codec_type === "video" && !s.disposition?.attached_pic)?.duration);
484
668
  return {
485
- seconds: Number(info.format?.duration ?? 0),
669
+ seconds: picture > 0 ? picture : Number(info.format?.duration ?? 0),
486
670
  hasAudio: (info.streams ?? []).some((s) => s.codec_type === "audio"),
487
671
  };
488
672
  };
@@ -257,7 +257,7 @@ export declare function resolveStopAtPark(stop: RunStop, runId: string, deps: Pi
257
257
  export declare function stageWord(stage: string): string;
258
258
  export declare function stepName(kind: string | undefined): string;
259
259
  export declare function isShowAd(run: Pick<VideoRun, "moduleOwned">): boolean;
260
- export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId" | "moduleOwned">): string;
260
+ export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId">): string;
261
261
  export declare function stopLines(stop: ResolvedStop, runId: string, runUrl: string): string[];
262
262
  export declare function failedStoryboardNode(run: VideoRun): VideoRunNode | undefined;
263
263
  export declare function errorEchoesReasons(error: string | undefined, record: PlanFailureRecord | undefined): boolean;
@@ -410,8 +410,6 @@ export function isShowAd(run) {
410
410
  return run.moduleOwned === true;
411
411
  }
412
412
  export function reviewUrl(dashboardUrl, run) {
413
- if (isShowAd(run))
414
- return `${dashboardUrl}/video?ad=${run._id}`;
415
413
  if (run.workflowId)
416
414
  return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
417
415
  return `${dashboardUrl}/runs/${run._id}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.13",
3
+ "version": "2026.9.2000-beta.15",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",