@aicopycoders/exodus 2026.9.1400 → 2026.9.1700-beta.0

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.
@@ -0,0 +1,568 @@
1
+ #!/usr/bin/env node
2
+ // A plain first cut from a folder written by `exodus video pull`: the A-roll
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, a room-tone bed and the music
5
+ // bed go underneath, and one MP4 comes out ready to upload.
6
+ //
7
+ // node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
8
+ //
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.
18
+ //
19
+ // After the render it writes the whole ad's word times beside the MP4, as
20
+ // <out>.words.json and <out>.srt, for captioning in post.
21
+ import { execFileSync, spawnSync } from "node:child_process";
22
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
+ import path from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+
26
+ const USAGE = "usage: first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]";
27
+ const WIDTH = 1080;
28
+ const HEIGHT = 1920;
29
+ const FPS = 24;
30
+ const norm = (label) =>
31
+ `[${label}]scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
32
+ `pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS},format=yuv420p`;
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;
44
+
45
+ /** The word rule the planner sized every line with (scout models.ts wordCount). */
46
+ export function wordCount(text) {
47
+ const trimmed = (text ?? "").trim();
48
+ return trimmed ? trimmed.split(/\s+/).length : 0;
49
+ }
50
+
51
+ export function lineWordCounts(storyboard) {
52
+ const counts = new Map();
53
+ for (const line of storyboard?.script ?? []) {
54
+ if (typeof line?.lineId === "string") counts.set(line.lineId, wordCount(line.text));
55
+ }
56
+ return counts;
57
+ }
58
+
59
+ const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
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
+
78
+ export function scenePlans(storyboard) {
79
+ const plans = new Map();
80
+ for (const scene of storyboard?.scenes ?? []) {
81
+ if (typeof scene?.sceneIndex !== "number") continue;
82
+ plans.set(scene.sceneIndex, {
83
+ cutaway: CUTAWAY_KINDS.has(scene.kind),
84
+ narrated: narratedScene(scene, storyboard),
85
+ lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
86
+ cueLineId: typeof scene.cueLineId === "string" ? scene.cueLineId : null,
87
+ durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
88
+ });
89
+ }
90
+ return plans;
91
+ }
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
+
117
+ export function cueOffsetOnSpine(spine, counts, cueLineId) {
118
+ for (const seg of spine) {
119
+ const idx = seg.lineIds.indexOf(cueLineId);
120
+ if (idx === -1) continue;
121
+ let precedingWords = 0;
122
+ for (let i = 0; i < idx; i++) precedingWords += counts.get(seg.lineIds[i]) ?? 0;
123
+ let totalWords = 0;
124
+ for (const id of seg.lineIds) totalWords += counts.get(id) ?? 0;
125
+
126
+ let within = 0;
127
+ let method = "proportion";
128
+ if (seg.words && seg.words.length > 0) {
129
+ within = Math.max(0, seg.words[Math.min(precedingWords, seg.words.length - 1)]?.s ?? 0);
130
+ method = "words";
131
+ } else if (totalWords > 0) {
132
+ within = (precedingWords / totalWords) * seg.seconds;
133
+ }
134
+ within = Math.min(within, seg.seconds);
135
+ return { startSec: seg.startSec + within, method, spineSceneIndex: seg.sceneIndex };
136
+ }
137
+ return null;
138
+ }
139
+
140
+ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
141
+ const plans = scenePlans(storyboard);
142
+ const counts = lineWordCounts(storyboard);
143
+ const spine = [];
144
+ const cutaways = [];
145
+ const waiting = [];
146
+ const dropped = [];
147
+ let startSec = 0;
148
+
149
+ for (const scene of manifest?.scenes ?? []) {
150
+ const n = scene.sceneIndex;
151
+ const plan = plans.get(n) ?? { cutaway: false, lineIds: [], cueLineId: null, durationSec: null };
152
+ if (skip.has(n)) {
153
+ dropped.push({ n, reason: `scene ${n}: skipped by --skip` });
154
+ continue;
155
+ }
156
+ const nothing = `nothing to cut with (clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""})`;
157
+
158
+ if (plan.cutaway) {
159
+ if (exists(scene.clip)) {
160
+ waiting.push({
161
+ sceneIndex: n, source: "clip", file: scene.clip,
162
+ seconds: probe(scene.clip).seconds, cueLineId: plan.cueLineId,
163
+ });
164
+ } else if (exists(scene.keyframe)) {
165
+ waiting.push({
166
+ sceneIndex: n, source: "still", file: scene.keyframe,
167
+ seconds: plan.durationSec ?? 4, cueLineId: plan.cueLineId,
168
+ });
169
+ } else {
170
+ dropped.push({ n, reason: `cutaway ${n}: ${nothing}` });
171
+ }
172
+ continue;
173
+ }
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
+
179
+ if (exists(scene.clip)) {
180
+ const clip = probe(scene.clip);
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;
214
+ continue;
215
+ }
216
+ if (exists(scene.keyframe)) {
217
+ const fit = voice
218
+ ? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
219
+ : { seconds: plan.durationSec ?? 4, atempo: 1, holdSec: 0 };
220
+ spine.push({
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,
224
+ // #1689: on a narrated scene the words are the voice track's, and the
225
+ // voice starts where the segment does, so they need no offset here.
226
+ words: voice ? readWords(scene.words) : null,
227
+ note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${fit.seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
228
+ });
229
+ startSec += fit.seconds;
230
+ continue;
231
+ }
232
+ dropped.push({ n, reason: `scene ${n}: ${nothing}` });
233
+ }
234
+
235
+ for (const c of waiting) {
236
+ const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
237
+ if (!cue) {
238
+ const why = c.cueLineId
239
+ ? `${c.cueLineId} is spoken by no scene in the cut`
240
+ : "the storyboard names no cue line";
241
+ dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${why}` });
242
+ continue;
243
+ }
244
+ cutaways.push({ ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex });
245
+ }
246
+ cutaways.sort((a, b) => a.startSec - b.startSec);
247
+ for (let i = 0; i < cutaways.length; i++) {
248
+ const c = cutaways[i];
249
+ const next = cutaways[i + 1];
250
+ const end = Math.min(startSec, next ? next.startSec : Infinity);
251
+ if (c.startSec + c.seconds > end) {
252
+ c.seconds = Math.max(0, end - c.startSec);
253
+ c.cutShortBy = next && end === next.startSec ? `cutaway ${next.sceneIndex} starts` : "the ad ends";
254
+ }
255
+ }
256
+
257
+ return {
258
+ spine,
259
+ cutaways,
260
+ left: dropped.sort((a, b) => a.n - b.n).map((d) => d.reason),
261
+ };
262
+ }
263
+
264
+ function sec(n) {
265
+ return (Number.isFinite(n) ? Math.max(0, n) : 0).toFixed(3);
266
+ }
267
+
268
+ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
269
+ const inputs = [];
270
+ let count = 0;
271
+ const addInput = (...args) => {
272
+ inputs.push(...args);
273
+ return count++;
274
+ };
275
+ const filters = [];
276
+ const pairs = [];
277
+
278
+ for (const seg of timeline.spine) {
279
+ const i =
280
+ seg.source === "clip"
281
+ ? addInput("-i", resolve(seg.file))
282
+ : addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
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") {
290
+ const v = addInput("-i", resolve(seg.voice));
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}]`);
295
+ } else {
296
+ filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
297
+ }
298
+ pairs.push(`[v${i}][a${i}]`);
299
+ }
300
+
301
+ // A clip whose audio ends before its video would otherwise end the whole cut
302
+ // there under -shortest; padding lets the video decide the length.
303
+ filters.push(`${pairs.join("")}concat=n=${pairs.length}:v=1:a=1[vcat][voicecat]`);
304
+ filters.push(`[voicecat]apad[voice]`);
305
+
306
+ let videoLabel = "vcat";
307
+ timeline.cutaways.forEach((c, j) => {
308
+ const i =
309
+ c.source === "clip"
310
+ ? addInput("-i", resolve(c.file))
311
+ : addInput("-loop", "1", "-t", String(c.seconds), "-i", resolve(c.file));
312
+ const end = c.startSec + c.seconds;
313
+ filters.push(
314
+ `${norm(`${i}:v`)},trim=0:${sec(c.seconds)},setpts=PTS-STARTPTS+${sec(c.startSec)}/TB[cv${j}]`,
315
+ );
316
+ // eof_action=pass keeps the spine showing once the cutaway's frames end.
317
+ filters.push(
318
+ `[${videoLabel}][cv${j}]overlay=enable=between(t\\,${sec(c.startSec)}\\,${sec(end)}):eof_action=pass[ov${j}]`,
319
+ );
320
+ videoLabel = `ov${j}`;
321
+ });
322
+
323
+ filters.push(`${ROOM_TONE}[room]`);
324
+ const beds = ["[room]"];
325
+ if (music) {
326
+ const m = addInput("-stream_loop", "-1", "-i", music);
327
+ filters.push(`[${m}:a]${stereo},volume=${MUSIC_VOLUME}[bed]`);
328
+ beds.push("[bed]");
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";
334
+
335
+ return [
336
+ "-y", "-hide_banner", "-loglevel", "error",
337
+ ...inputs,
338
+ "-filter_complex", filters.join(";"),
339
+ "-map", `[${videoLabel}]`, "-map", `[${audioLabel}]`,
340
+ "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-r", String(FPS),
341
+ "-c:a", "aac", "-b:a", "192k",
342
+ "-movflags", "+faststart", "-shortest",
343
+ out,
344
+ ];
345
+ }
346
+
347
+ export function wordRows(timeline) {
348
+ const ms = (n) => Math.round(n * 1000) / 1000;
349
+ const rows = [];
350
+ for (const seg of timeline.spine) {
351
+ for (const w of seg.words ?? []) {
352
+ const text = (w.w ?? "").trim();
353
+ const start = seg.startSec + w.s;
354
+ const end = seg.startSec + w.e;
355
+ if (text && end > start) {
356
+ rows.push({ text, start: ms(start), end: ms(end), scene: seg.sceneIndex });
357
+ }
358
+ }
359
+ }
360
+ return rows;
361
+ }
362
+
363
+ const SRT_MAX_WORDS = 5;
364
+ const SRT_GAP_SEC = 1.0;
365
+
366
+ export function srtCues(rows) {
367
+ const cues = [];
368
+ let cue = [];
369
+ for (let i = 0; i < rows.length; i++) {
370
+ cue.push(rows[i]);
371
+ const next = rows[i + 1];
372
+ const ends =
373
+ !next ||
374
+ /[.!?,;:]$/.test(rows[i].text) ||
375
+ cue.length >= SRT_MAX_WORDS ||
376
+ next.scene !== rows[i].scene ||
377
+ next.start - rows[i].end > SRT_GAP_SEC;
378
+ if (ends) {
379
+ cues.push(cue);
380
+ cue = [];
381
+ }
382
+ }
383
+ return cues;
384
+ }
385
+
386
+ export function srtTime(seconds) {
387
+ const ms = Math.max(0, Math.round(seconds * 1000));
388
+ const pad = (n, width) => String(n).padStart(width, "0");
389
+ return (
390
+ `${pad(Math.floor(ms / 3600000), 2)}:${pad(Math.floor(ms / 60000) % 60, 2)}:` +
391
+ `${pad(Math.floor(ms / 1000) % 60, 2)},${pad(ms % 1000, 3)}`
392
+ );
393
+ }
394
+
395
+ export function toSrt(rows) {
396
+ return srtCues(rows)
397
+ .map((cue, i) => {
398
+ const text = cue.map((r) => r.text).join(" ");
399
+ return `${i + 1}\n${srtTime(cue[0].start)} --> ${srtTime(cue[cue.length - 1].end)}\n${text}\n`;
400
+ })
401
+ .join("\n");
402
+ }
403
+
404
+ export function sidecarPaths(outPath) {
405
+ const dir = path.dirname(outPath);
406
+ const base = path.basename(outPath, path.extname(outPath));
407
+ return { words: path.join(dir, `${base}.words.json`), srt: path.join(dir, `${base}.srt`) };
408
+ }
409
+
410
+ export function inTheCut(timeline) {
411
+ const rows = [];
412
+ for (const seg of timeline.spine) {
413
+ const label = seg.source === "clip" ? seg.file : `still ${seg.file}`;
414
+ rows.push({ n: seg.sceneIndex, line: `scene ${seg.sceneIndex}: ${label}${seg.note}` });
415
+ }
416
+ for (const c of timeline.cutaways) {
417
+ const label = c.source === "clip" ? c.file : `still ${c.file}`;
418
+ const how =
419
+ c.method === "words"
420
+ ? "word times"
421
+ : `proportion: scene ${c.spineSceneIndex} has no word times`;
422
+ const cut = c.cutShortBy ? `, cut short where ${c.cutShortBy}` : "";
423
+ rows.push({
424
+ n: c.sceneIndex,
425
+ line:
426
+ `cutaway ${c.sceneIndex}: ${label} at ${c.startSec.toFixed(2)}s for ${c.seconds.toFixed(1)}s, ` +
427
+ `cued to ${c.cueLineId} in scene ${c.spineSceneIndex} (${how})${cut}`,
428
+ });
429
+ }
430
+ return rows.sort((a, b) => a.n - b.n).map((r) => r.line);
431
+ }
432
+
433
+ export function noWordTimes(timeline) {
434
+ const rows = timeline.spine
435
+ .filter((seg) => !seg.words || seg.words.length === 0)
436
+ .map((seg) => `scene ${seg.sceneIndex} (no words.json)`);
437
+ for (const c of timeline.cutaways) rows.push(`scene ${c.sceneIndex} (cutaway)`);
438
+ return rows;
439
+ }
440
+
441
+ function fail(code, message) {
442
+ console.error(message);
443
+ process.exit(code);
444
+ }
445
+
446
+ function parseArgs(argv) {
447
+ const opts = { dir: undefined, out: undefined, skip: new Set(), music: true };
448
+ for (let i = 0; i < argv.length; i++) {
449
+ const a = argv[i];
450
+ if (a === "--out") opts.out = argv[++i];
451
+ else if (a === "--skip") opts.skip = new Set((argv[++i] ?? "").split(",").filter(Boolean).map(Number));
452
+ else if (a === "--no-music") opts.music = false;
453
+ else if (a.startsWith("--")) fail(2, `unknown flag ${a}\n${USAGE}`);
454
+ else if (opts.dir === undefined) opts.dir = a;
455
+ else fail(2, `unexpected argument ${a}\n${USAGE}`);
456
+ }
457
+ if (!opts.dir) fail(2, USAGE);
458
+ return opts;
459
+ }
460
+
461
+ function main() {
462
+ const opts = parseArgs(process.argv.slice(2));
463
+
464
+ for (const bin of ["ffmpeg", "ffprobe"]) {
465
+ if (spawnSync(bin, ["-version"]).status !== 0) {
466
+ fail(2, `${bin} is not installed. Install ffmpeg (brew install ffmpeg / apt install ffmpeg).`);
467
+ }
468
+ }
469
+ const encoders = spawnSync("ffmpeg", ["-hide_banner", "-encoders"]).stdout?.toString() ?? "";
470
+ if (!/\blibx264\b/.test(encoders)) {
471
+ fail(2, "This ffmpeg has no libx264 encoder (H.264). Install a full ffmpeg build: brew install ffmpeg / apt install ffmpeg.");
472
+ }
473
+
474
+ const dir = opts.dir;
475
+ const manifestPath = path.join(dir, "manifest.json");
476
+ if (!existsSync(manifestPath)) {
477
+ fail(2, `${manifestPath} not found. Run: exodus video pull <runId> --out ${dir}`);
478
+ }
479
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
480
+ const out = path.resolve(opts.out ?? path.join(dir, "cut.mp4"));
481
+ const here = (file) => path.join(dir, file);
482
+ const exists = (file) => Boolean(file) && existsSync(here(file));
483
+
484
+ const readJson = (file) => {
485
+ if (!exists(file)) return null;
486
+ try {
487
+ return JSON.parse(readFileSync(here(file), "utf8"));
488
+ } catch {
489
+ return null;
490
+ }
491
+ };
492
+ const probe = (file) => {
493
+ const raw = execFileSync("ffprobe", [
494
+ "-v", "error", "-show_entries", "stream=codec_type:format=duration", "-of", "json", here(file),
495
+ ]).toString();
496
+ const info = JSON.parse(raw);
497
+ return {
498
+ seconds: Number(info.format?.duration ?? 0),
499
+ hasAudio: (info.streams ?? []).some((s) => s.codec_type === "audio"),
500
+ };
501
+ };
502
+
503
+ const storyboard = readJson(manifest.storyboard);
504
+ if (!storyboard) {
505
+ console.error(`Warning: ${manifest.storyboard ?? "storyboard.json"} is missing or unreadable, so every scene is cut as A-roll and no cutaway is placed.`);
506
+ }
507
+ const timeline = buildTimeline({
508
+ manifest,
509
+ storyboard,
510
+ skip: opts.skip,
511
+ probe,
512
+ exists,
513
+ readWords: (file) => {
514
+ const rows = readJson(file);
515
+ return Array.isArray(rows) && rows.length > 0 ? rows : null;
516
+ },
517
+ });
518
+ if (timeline.spine.length === 0) {
519
+ fail(1, `No scene has a clip or a keyframe. Check: exodus video status ${manifest.runId}`);
520
+ }
521
+
522
+ const music = opts.music && exists(manifest.music) ? manifest.music : null;
523
+ const ffmpegArgs = buildFfmpegArgs(timeline, {
524
+ resolve: here,
525
+ music: music ? here(music) : null,
526
+ out,
527
+ });
528
+ const res = spawnSync("ffmpeg", ffmpegArgs, { stdio: "inherit" });
529
+ if (res.status !== 0) {
530
+ const quoted = ffmpegArgs.map((a) => (/[\s\[\];]/.test(a) ? `'${a}'` : a)).join(" ");
531
+ fail(1, `ffmpeg failed. The command it ran:\n ffmpeg ${quoted}`);
532
+ }
533
+
534
+ const rows = wordRows(timeline);
535
+ const sidecars = sidecarPaths(out);
536
+ if (rows.length > 0) {
537
+ writeFileSync(sidecars.words, `${JSON.stringify(rows, null, 2)}\n`);
538
+ writeFileSync(sidecars.srt, toSrt(rows));
539
+ } else {
540
+ rmSync(sidecars.words, { force: true });
541
+ rmSync(sidecars.srt, { force: true });
542
+ }
543
+
544
+ const duration = execFileSync("ffprobe", [
545
+ "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", out,
546
+ ]).toString().trim();
547
+ console.log(`Wrote ${out} (${Number(duration).toFixed(1)}s, ${WIDTH}x${HEIGHT})`);
548
+ console.log("In the cut:");
549
+ for (const line of inTheCut(timeline)) console.log(" " + line);
550
+ if (timeline.left.length > 0) {
551
+ console.log("Left out:");
552
+ for (const line of timeline.left) console.log(" " + line);
553
+ }
554
+ if (rows.length > 0) {
555
+ const scenes = [...new Set(rows.map((r) => r.scene))].join(", ");
556
+ console.log(
557
+ `Words: ${rows.length} words from scenes ${scenes} -> ${path.basename(sidecars.words)}, ${path.basename(sidecars.srt)}`,
558
+ );
559
+ } else {
560
+ console.log("Words: none delivered, no caption files written");
561
+ }
562
+ const silent = noWordTimes(timeline);
563
+ if (silent.length > 0) console.log(`No word times: ${silent.join(", ")}`);
564
+ console.log(`Room tone: under the whole ad. Music bed: ${music ?? "none"}`);
565
+ console.log(`Upload it: exodus video upload ${manifest.runId} --file ${out}`);
566
+ }
567
+
568
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();