@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,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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,
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
133
|
-
|
|
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,
|
|
136
|
-
|
|
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
|
-
|
|
197
|
-
if (seg.
|
|
198
|
-
|
|
199
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
234
|
-
|
|
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
|
|