@aicopycoders/exodus 2026.9.1100-beta.2 → 2026.9.1100-beta.3

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.
@@ -144,6 +144,9 @@ failed[] files that did not download: {file, url, er
144
144
  How to read it before cutting:
145
145
 
146
146
  - `clipStatus: "done"` and a `clip` filename: the scene is cuttable.
147
+ - `wordsFrom: "voice"` (or narration in the storyboard with no on-camera line):
148
+ a narrated scene. Its clip is picture only; the voice track is the sound.
149
+ Never cut a narrated scene with the clip's own audio.
147
150
  - `flagged: true` is a warning, not a block. The clip was delivered anyway; the
148
151
  findings say what the QC model saw (`wrong-character`, `eyeline-off`,
149
152
  `set-drift`, `speech-cutoff`, `framing-off`). Tell the user which scenes are
@@ -163,10 +166,15 @@ How to read it before cutting:
163
166
 
164
167
  Use the bundled script. It reads the manifest, puts every A-roll scene in order
165
168
  as the spine, lays each cutaway over the spine at the moment its line is spoken,
166
- normalizes everything to 1080x1920 at 24 fps, fills a silent clip with the
167
- scene's voice track, lays the music bed underneath at a low level, and writes one
168
- MP4 with faststart for upload. The path is from the workspace root; from a brand
169
- subfolder, prefix `../`:
169
+ normalizes everything to 1080x1920 at 24 fps, and writes one MP4 with faststart
170
+ for upload. The audio track comes first: a narrated scene (narration in the
171
+ storyboard, no on-camera line) plays its voice track and nothing the clip
172
+ recorded, and the picture is fitted to the voice (a longer clip is trimmed, a
173
+ longer voice is sped up to at most 1.26x and then the last frame holds). A
174
+ dialogue scene keeps the line it performed. Every segment is loudness-normalized
175
+ to -16 LUFS, a continuous room-tone bed runs under the whole ad so the joins do
176
+ not read as dead air, and the music bed sits underneath at a low level. The path
177
+ is from the workspace root; from a brand subfolder, prefix `../`:
170
178
 
171
179
  ```
172
180
  node .claude/skills/exodus-video/scripts/first-cut.mjs ./ad-<runId>
@@ -1,15 +1,20 @@
1
1
  #!/usr/bin/env node
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
- // spine at the moment its cued line is spoken, the music bed goes underneath,
5
- // and one MP4 comes out ready to upload.
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.
6
6
  //
7
7
  // node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
8
8
  //
9
- // A scene with a clip contributes the clip. A clip with no audio stream gets
10
- // the scene's voice track, or silence. A scene with no clip but a keyframe
11
- // becomes a still for the length of its voice track, else the storyboard's
12
- // planned duration, with the voice if one was delivered. --skip drops scenes.
9
+ // The audio track comes first. A narrated scene (the storyboard gives it
10
+ // narration and no on-camera line) plays its voice track and NOTHING the clip
11
+ // recorded: the voice replaces the clip's audio, and the picture is fitted to
12
+ // the voice, trimmed when the clip runs longer, sped up to at most 1.26x and
13
+ // then held on its last frame when the voice runs longer. 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
+ // --skip drops scenes.
13
18
  //
14
19
  // After the render it writes the whole ad's word times beside the MP4, as
15
20
  // <out>.words.json and <out>.srt, for captioning in post.
@@ -26,6 +31,16 @@ const norm = (label) =>
26
31
  `[${label}]scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
27
32
  `pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS},format=yuv420p`;
28
33
  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";
36
+ /** Air after the last spoken word of a narrated scene, so the line lands. */
37
+ export const TAIL_AIR_SEC = 0.8;
38
+ /** The most a voice track may be sped up to fit its clip. */
39
+ export const ATEMPO_CAP = 1.26;
40
+ /** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
41
+ const ROOM_TONE =
42
+ "anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
43
+ const MUSIC_VOLUME = 0.18;
29
44
 
30
45
  /** The word rule the planner sized every line with (scout models.ts wordCount). */
31
46
  export function wordCount(text) {
@@ -43,12 +58,30 @@ export function lineWordCounts(storyboard) {
43
58
 
44
59
  const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
45
60
 
61
+ const hasText = (value) => typeof value === "string" && value.trim().length > 0;
62
+
63
+ /**
64
+ * A narrated scene: on a script-bound storyboard (one that carries the member's
65
+ * script lines) the scene has narration and no on-camera line. That is the same
66
+ * fact the server uses to leave a narrator scene's clip mute and its voice track
67
+ * separate. A storyboard without script lines predates that rule: its voiced
68
+ * clips were lip-synced to the voice on the server, so they keep their own audio.
69
+ */
70
+ export function narratedScene(scene, storyboard) {
71
+ const scriptBound = Array.isArray(storyboard?.script) && storyboard.script.length > 0;
72
+ const spoken = (Array.isArray(scene?.dialogue) ? scene.dialogue : []).some(
73
+ (turn) => hasText(turn?.line) || hasText(turn?.text),
74
+ );
75
+ return scriptBound && hasText(scene?.voText) && !spoken;
76
+ }
77
+
46
78
  export function scenePlans(storyboard) {
47
79
  const plans = new Map();
48
80
  for (const scene of storyboard?.scenes ?? []) {
49
81
  if (typeof scene?.sceneIndex !== "number") continue;
50
82
  plans.set(scene.sceneIndex, {
51
83
  cutaway: CUTAWAY_KINDS.has(scene.kind),
84
+ narrated: narratedScene(scene, storyboard),
52
85
  lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
53
86
  cueLineId: typeof scene.cueLineId === "string" ? scene.cueLineId : null,
54
87
  durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
@@ -57,6 +90,30 @@ export function scenePlans(storyboard) {
57
90
  return plans;
58
91
  }
59
92
 
93
+ /**
94
+ * Fit a picture to its voice track. The segment runs as long as the voice plus
95
+ * a beat of air. A clip that runs longer is trimmed; a voice that runs longer
96
+ * is sped up to at most ATEMPO_CAP and the clip's last frame is held for
97
+ * whatever is still left. A still (no clip) simply runs the voice's length.
98
+ */
99
+ export function fitNarration({ clipSeconds, voiceSeconds }) {
100
+ const target = voiceSeconds + TAIL_AIR_SEC;
101
+ if (clipSeconds === null) return { seconds: target, atempo: 1, holdSec: 0, trimmedBy: 0 };
102
+ if (target <= clipSeconds) {
103
+ return { seconds: target, atempo: 1, holdSec: 0, trimmedBy: clipSeconds - target };
104
+ }
105
+ const room = Math.max(clipSeconds - TAIL_AIR_SEC, 0.1);
106
+ const atempo = Math.min(ATEMPO_CAP, voiceSeconds / room);
107
+ const fitted = voiceSeconds / atempo + TAIL_AIR_SEC;
108
+ if (fitted <= clipSeconds) return { seconds: fitted, atempo, holdSec: 0, trimmedBy: 0 };
109
+ return { seconds: fitted, atempo, holdSec: fitted - clipSeconds, trimmedBy: 0 };
110
+ }
111
+
112
+ function scaleWords(words, atempo) {
113
+ if (!words || atempo === 1) return words;
114
+ return words.map((w) => ({ ...w, s: w.s / atempo, e: w.e / atempo }));
115
+ }
116
+
60
117
  export function cueOffsetOnSpine(spine, counts, cueLineId) {
61
118
  for (const seg of spine) {
62
119
  const idx = seg.lineIds.indexOf(cueLineId);
@@ -115,31 +172,61 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
115
172
  continue;
116
173
  }
117
174
 
175
+ const narrated = plan.narrated || scene.wordsFrom === "voice";
176
+ const voice = exists(scene.voice) ? scene.voice : null;
177
+ const flag = scene.flagged ? " (flagged)" : "";
178
+
118
179
  if (exists(scene.clip)) {
119
180
  const clip = probe(scene.clip);
120
- const voice = clip.hasAudio || !exists(scene.voice) ? null : scene.voice;
121
- const flag = scene.flagged ? " (flagged)" : "";
122
- const silent = clip.hasAudio ? "" : voice ? `, silent clip + ${voice}` : ", silent clip";
123
- spine.push({
124
- sceneIndex: n, source: "clip", file: scene.clip, seconds: clip.seconds,
125
- hasAudio: clip.hasAudio, voice, startSec, lineIds: plan.lineIds,
126
- words: readWords(scene.words), note: `${flag}${silent}`,
127
- });
128
- startSec += clip.seconds;
181
+ const base = {
182
+ sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds,
183
+ startSec, lineIds: plan.lineIds,
184
+ };
185
+ if (voice && (narrated || !clip.hasAudio)) {
186
+ const fit = fitNarration({ clipSeconds: clip.seconds, voiceSeconds: probe(voice).seconds });
187
+ const how = [];
188
+ if (fit.trimmedBy > 0.05) how.push(`clip trimmed ${clip.seconds.toFixed(1)}s -> ${fit.seconds.toFixed(1)}s`);
189
+ if (fit.atempo > 1.001) how.push(`voice sped up ${fit.atempo.toFixed(2)}x`);
190
+ if (fit.holdSec > 0.05) how.push(`last frame held ${fit.holdSec.toFixed(1)}s`);
191
+ spine.push({
192
+ ...base, audio: "narration", voice, seconds: fit.seconds, atempo: fit.atempo,
193
+ holdSec: fit.holdSec, words: scaleWords(readWords(scene.words), fit.atempo),
194
+ note: `${flag}, ${voice} replaces the clip's audio${how.length ? ` (${how.join(", ")})` : ""}`,
195
+ });
196
+ } else if (narrated) {
197
+ spine.push({
198
+ ...base, audio: "silence", voice: null, seconds: clip.seconds, atempo: 1, holdSec: 0,
199
+ words: null,
200
+ note: `${flag}, narrated scene without its voice track: the clip's own audio is muted`,
201
+ });
202
+ } else if (clip.hasAudio) {
203
+ spine.push({
204
+ ...base, audio: "clip", voice: null, seconds: clip.seconds, atempo: 1, holdSec: 0,
205
+ words: readWords(scene.words), note: `${flag}, its own dialogue`,
206
+ });
207
+ } else {
208
+ spine.push({
209
+ ...base, audio: "silence", voice: null, seconds: clip.seconds, atempo: 1, holdSec: 0,
210
+ words: null, note: `${flag}, silent clip`,
211
+ });
212
+ }
213
+ startSec += spine[spine.length - 1].seconds;
129
214
  continue;
130
215
  }
131
216
  if (exists(scene.keyframe)) {
132
- const voice = exists(scene.voice) ? scene.voice : null;
133
- const seconds = voice ? probe(voice).seconds : (plan.durationSec ?? 4);
217
+ const fit = voice
218
+ ? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
219
+ : { seconds: plan.durationSec ?? 4, atempo: 1, holdSec: 0 };
134
220
  spine.push({
135
- sceneIndex: n, source: "still", file: scene.keyframe, seconds,
136
- hasAudio: false, voice, startSec, lineIds: plan.lineIds,
221
+ sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null,
222
+ seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, atempo: 1, holdSec: 0,
223
+ startSec, lineIds: plan.lineIds,
137
224
  // #1689: on a narrated scene the words are the voice track's, and the
138
225
  // voice starts where the segment does, so they need no offset here.
139
226
  words: voice ? readWords(scene.words) : null,
140
- note: `${voice ? ` + ${voice}` : ", silent"} (${seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
227
+ note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${fit.seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
141
228
  });
142
- startSec += seconds;
229
+ startSec += fit.seconds;
143
230
  continue;
144
231
  }
145
232
  dropped.push({ n, reason: `scene ${n}: ${nothing}` });
@@ -193,14 +280,20 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
193
280
  seg.source === "clip"
194
281
  ? addInput("-i", resolve(seg.file))
195
282
  : addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
196
- filters.push(`${norm(`${i}:v`)}[v${i}]`);
197
- if (seg.hasAudio) {
198
- filters.push(`[${i}:a]${stereo}[a${i}]`);
199
- } else if (seg.voice) {
283
+ const fit = [];
284
+ if (seg.source === "clip" && seg.clipSeconds !== null && seg.seconds < seg.clipSeconds - 0.01) {
285
+ fit.push(`trim=0:${sec(seg.seconds)},setpts=PTS-STARTPTS`);
286
+ }
287
+ if (seg.holdSec > 0.01) fit.push(`tpad=stop_mode=clone:stop_duration=${sec(seg.holdSec)}`);
288
+ filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
289
+ if (seg.audio === "narration") {
200
290
  const v = addInput("-i", resolve(seg.voice));
201
- filters.push(`[${v}:a]${stereo},apad,atrim=0:${seg.seconds}[a${i}]`);
291
+ const tempo = seg.atempo > 1.001 ? `atempo=${seg.atempo.toFixed(4)},` : "";
292
+ filters.push(`[${v}:a]${stereo},${tempo}${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
293
+ } else if (seg.audio === "clip") {
294
+ filters.push(`[${i}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
202
295
  } else {
203
- filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${seg.seconds}[a${i}]`);
296
+ filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
204
297
  }
205
298
  pairs.push(`[v${i}][a${i}]`);
206
299
  }
@@ -227,13 +320,17 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
227
320
  videoLabel = `ov${j}`;
228
321
  });
229
322
 
230
- let audioLabel = "voice";
323
+ filters.push(`${ROOM_TONE}[room]`);
324
+ const beds = ["[room]"];
231
325
  if (music) {
232
326
  const m = addInput("-stream_loop", "-1", "-i", music);
233
- filters.push(`[${m}:a]${stereo},volume=0.18[bed]`);
234
- filters.push(`[voice][bed]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[mix]`);
235
- audioLabel = "mix";
327
+ filters.push(`[${m}:a]${stereo},volume=${MUSIC_VOLUME}[bed]`);
328
+ beds.push("[bed]");
236
329
  }
330
+ filters.push(
331
+ `[voice]${beds.join("")}amix=inputs=${beds.length + 1}:duration=first:dropout_transition=0:normalize=0[mix]`,
332
+ );
333
+ const audioLabel = "mix";
237
334
 
238
335
  return [
239
336
  "-y", "-hide_banner", "-loglevel", "error",
@@ -464,7 +561,7 @@ function main() {
464
561
  }
465
562
  const silent = noWordTimes(timeline);
466
563
  if (silent.length > 0) console.log(`No word times: ${silent.join(", ")}`);
467
- console.log(`Music bed: ${music ?? "none"}`);
564
+ console.log(`Room tone: under the whole ad. Music bed: ${music ?? "none"}`);
468
565
  console.log(`Upload it: exodus video upload ${manifest.runId} --file ${out}`);
469
566
  }
470
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1100-beta.2",
3
+ "version": "2026.9.1100-beta.3",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",