@tandryukha/aidemo 0.8.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.
package/src/compose.ts ADDED
@@ -0,0 +1,937 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { resolve, isAbsolute } from "node:path";
3
+ import { Project } from "./project.js";
4
+ import type { Storyboard, Timeline, IdleSpan, Card, Output, Loudness } from "./types.js";
5
+ import { TimelineSchema, VoiceManifestSchema, LoudnessSchema } from "./types.js";
6
+ import type { Cue } from "./captions.js";
7
+ import { renderCaptionPngs } from "./caption-render.js";
8
+ import { renderCardPng } from "./cards.js";
9
+ import { renderCursorPng } from "./cursor.js";
10
+ import { buildZoomFilter, type ZoomEvent } from "./zoom.js";
11
+ import {
12
+ buildCursorFilter,
13
+ type CursorPoint,
14
+ type HideWindow,
15
+ } from "./cursor-overlay.js";
16
+ import { runFfmpeg, probeDurationMs, probeVideoDims } from "./ffmpeg.js";
17
+ import {
18
+ ensureDir,
19
+ readJson,
20
+ exists,
21
+ log,
22
+ ok,
23
+ step,
24
+ type SceneProgress,
25
+ } from "./util.js";
26
+
27
+ /** Idle "thinking" spans are trimmed down to at most this, then time-stretched. */
28
+ const IDLE_CAP_MS = 400;
29
+ const FPS = 30;
30
+ /** Bounds on how much we retime a scene before freezing the last frame instead. */
31
+ const MAX_STRETCH = 1.6; // don't slow real motion beyond this; freeze-hold the rest
32
+ const MIN_FACTOR = 0.5; // don't speed up beyond 2x
33
+
34
+ /**
35
+ * Turns the raw recording + timeline.json + narration.mp3 (+ captions,
36
+ * +optional music/zoom/cards) into the final MP4. Per scene: keep non-idle
37
+ * content plus a capped sliver of idle, then time-stretch that segment to
38
+ * exactly match the scene's narration length. This kills dead time AND locks
39
+ * audio/video in sync. Then, in order: auto-zoom pass over the content,
40
+ * intro/outro cards around it, caption overlay, audio mux with ducked music.
41
+ */
42
+ export async function compose(
43
+ project: Project,
44
+ storyboard: Storyboard,
45
+ opts: SceneProgress = {}
46
+ ): Promise<void> {
47
+ step("Composing final video (ffmpeg)");
48
+
49
+ const timeline = TimelineSchema.parse(await readJson(project.timelinePath));
50
+ const voice = VoiceManifestSchema.parse(
51
+ await readJson(project.voiceManifestPath)
52
+ );
53
+ await warnStaleCaptions(project);
54
+ const tmp = project.composeTmpDir;
55
+ await fs.rm(tmp, { recursive: true, force: true });
56
+ await ensureDir(tmp);
57
+ await ensureDir(resolve(project.outputPath, ".."));
58
+
59
+ const rawVideo = await project.resolveRawVideo();
60
+ // Raw video may be recorded at a higher pixel density than the storyboard's
61
+ // logical size (native/OBS capture on retina). Everything overlay-related
62
+ // scales by pxScale so captions/cards/zoom line up at any density.
63
+ const { width: outW, height: outH } = await probeVideoDims(rawVideo);
64
+ const pxScale = outW / storyboard.video.width;
65
+
66
+ // Map scene id -> narration duration; compose target = narration + gap.
67
+ const voiceById = new Map(voice.scenes.map((s) => [s.id, s.durationMs]));
68
+ const sceneById = new Map(storyboard.scenes.map((s) => [s.id, s]));
69
+
70
+ const zoomCfg =
71
+ storyboard.zoom && storyboard.zoom.enabled !== false ? storyboard.zoom : null;
72
+ const zoomEvents: ZoomEvent[] = [];
73
+ let outCursorMs = 0;
74
+
75
+ // Compose-time cursor overlay accumulators (opt-in `cursor` block). We collect
76
+ // the recorded path — remapped into final content time + output pixels, like
77
+ // the focus events — plus the content-time windows of any `hideScenes`.
78
+ const cursorCfg = storyboard.cursor ?? null;
79
+ const hideSceneIds = new Set(cursorCfg?.hideScenes ?? []);
80
+ const cursorPts: CursorPoint[] = [];
81
+ const hideWindows: HideWindow[] = [];
82
+ let cursorSampleCount = 0;
83
+
84
+ const sceneVideos: string[] = [];
85
+ const sceneTotal = timeline.scenes.length;
86
+ for (let i = 0; i < timeline.scenes.length; i++) {
87
+ const tl = timeline.scenes[i];
88
+ opts.onSceneStart?.(tl.id, i, sceneTotal);
89
+ const narrMs = voiceById.get(tl.id) ?? 0;
90
+ const targetMs = narrMs + voice.gapMs;
91
+ if (targetMs <= 0) {
92
+ log(`scene ${tl.id}: no narration, skipping`);
93
+ opts.onSceneComplete?.(tl.id, i, sceneTotal);
94
+ continue;
95
+ }
96
+
97
+ const keeps = keepIntervals(tl, timeline.leadInMs);
98
+ const rawSegPath = resolve(tmp, `scene-${i}-raw.mp4`);
99
+ await extractAndConcat(rawVideo, keeps, rawSegPath, tmp, i);
100
+
101
+ const srcMs = await probeDurationMs(rawSegPath);
102
+ // Retime the segment toward the narration length, but only within
103
+ // [MIN_FACTOR, MAX_STRETCH]. If narration still needs more time, hold
104
+ // (freeze) the last frame for the remainder — natural for a static page,
105
+ // and far better than 3x slow-motion.
106
+ const ratio = targetMs / Math.max(srcMs, 1);
107
+ const factor = Math.min(MAX_STRETCH, Math.max(MIN_FACTOR, ratio));
108
+ let stretchedMs = factor * srcMs;
109
+ // A scene whose active video exceeds 2x its narration would OVERRUN its
110
+ // narration slot — the narration track is fixed, so every later scene's
111
+ // video drifts behind its audio and the finale slides past the end of the
112
+ // video. Trim the segment tail instead: losing late dwell is far better
113
+ // than global A/V desync (bit us live 2026-07-06 when a 31s player stall
114
+ // inflated one scene's active span).
115
+ let overrunTrimMs = 0;
116
+ if (stretchedMs > targetMs + 40) {
117
+ overrunTrimMs = stretchedMs - targetMs;
118
+ stretchedMs = targetMs;
119
+ }
120
+ const holdMs = Math.max(0, targetMs - stretchedMs);
121
+ let vf = `setpts=${factor.toFixed(6)}*PTS`;
122
+ if (overrunTrimMs > 0) {
123
+ vf += `,trim=duration=${(targetMs / 1000).toFixed(3)},setpts=PTS-STARTPTS`;
124
+ }
125
+ if (holdMs > 40) {
126
+ vf += `,tpad=stop_mode=clone:stop_duration=${(holdMs / 1000).toFixed(3)}`;
127
+ }
128
+
129
+ // Map this scene's focus events into final-video time for the zoom pass:
130
+ // raw time → offset inside the kept spans → stretched → + scene offset.
131
+ if (zoomCfg && sceneById.get(tl.id)?.zoom !== false) {
132
+ for (const ev of tl.focusEvents ?? []) {
133
+ const rawT = ev.tMs + timeline.leadInMs;
134
+ const local = offsetInKeeps(keeps, rawT) * factor;
135
+ zoomEvents.push({
136
+ tMs: outCursorMs + Math.min(local, stretchedMs),
137
+ x: ev.x * pxScale,
138
+ y: ev.y * pxScale,
139
+ scale: ev.scale,
140
+ holdMs: ev.holdMs,
141
+ });
142
+ }
143
+ }
144
+
145
+ // Cursor path for the compose-time overlay — same raw→content-time remap as
146
+ // the focus events, in output pixels. The scene's content-time span drives
147
+ // per-scene hide (`hideScenes`).
148
+ if (cursorCfg) {
149
+ const sceneStart = outCursorMs;
150
+ for (const s of tl.cursorSamples ?? []) {
151
+ const rawT = s.tMs + timeline.leadInMs;
152
+ const local = offsetInKeeps(keeps, rawT) * factor;
153
+ cursorPts.push({
154
+ t: (outCursorMs + Math.min(local, stretchedMs)) / 1000,
155
+ x: s.x * pxScale,
156
+ y: s.y * pxScale,
157
+ });
158
+ }
159
+ cursorSampleCount += (tl.cursorSamples ?? []).length;
160
+ if (hideSceneIds.has(tl.id)) {
161
+ const sceneEnd = outCursorMs + stretchedMs + (holdMs > 40 ? holdMs : 0);
162
+ hideWindows.push({ a: sceneStart / 1000, b: sceneEnd / 1000 });
163
+ }
164
+ }
165
+
166
+ outCursorMs += stretchedMs + (holdMs > 40 ? holdMs : 0);
167
+
168
+ const scenePath = resolve(tmp, `scene-${i}.mp4`);
169
+ await runFfmpeg([
170
+ "-i",
171
+ rawSegPath,
172
+ "-vf",
173
+ vf,
174
+ "-an",
175
+ "-r",
176
+ String(FPS),
177
+ "-c:v",
178
+ "libx264",
179
+ "-preset",
180
+ "veryfast",
181
+ "-crf",
182
+ "20",
183
+ "-pix_fmt",
184
+ "yuv420p",
185
+ scenePath,
186
+ ]);
187
+ sceneVideos.push(scenePath);
188
+ log(
189
+ `scene ${tl.id}: ${srcMs}ms -> ${targetMs}ms ` +
190
+ `(x${factor.toFixed(2)}${holdMs > 40 ? ` + ${Math.round(holdMs)}ms hold` : ""}${
191
+ overrunTrimMs > 0 ? ` - ${Math.round(overrunTrimMs)}ms tail trim` : ""
192
+ }, ${keeps.length} span(s))`
193
+ );
194
+ opts.onSceneComplete?.(tl.id, i, sceneTotal);
195
+ }
196
+
197
+ if (sceneVideos.length === 0) throw new Error("No scenes to compose.");
198
+
199
+ // Scene assembly. Default: stream-copy concat (hard cuts) — the fast,
200
+ // lossless path, untouched when no transition is set. Opt-in: crossfade the
201
+ // scene boundaries (re-encodes the join; total duration is preserved).
202
+ let content: string;
203
+ if (storyboard.transition && sceneVideos.length >= 2) {
204
+ const durMs = storyboard.transition.durationMs;
205
+ log(
206
+ `scene crossfade: ${durMs}ms x ${sceneVideos.length - 1} boundary/ies`
207
+ );
208
+ content = await crossfadeScenes(
209
+ sceneVideos,
210
+ durMs,
211
+ resolve(tmp, "content.mp4")
212
+ );
213
+ } else {
214
+ content = await concatSegments(
215
+ sceneVideos,
216
+ resolve(tmp, "content.mp4"),
217
+ tmp,
218
+ "scenes"
219
+ );
220
+ }
221
+
222
+ // Compose-time cursor overlay (opt-in `cursor` block). Draw the recorded
223
+ // cursor path onto the (cursor-free) content BEFORE the zoom pass, so the
224
+ // cursor zooms and pans with the frame exactly like a baked one would.
225
+ // `hidden` skips it entirely; `hideScenes` gates it off during those scenes.
226
+ if (cursorCfg) {
227
+ if (cursorSampleCount === 0) {
228
+ log(
229
+ "⚠ cursor: storyboard opts into the compose-time cursor, but this take " +
230
+ "has no recorded cursor path — re-run `record`/`render` with the cursor " +
231
+ "block present (the current recording may still show a baked cursor)."
232
+ );
233
+ } else if (cursorCfg.hidden) {
234
+ log("cursor: hidden (no overlay drawn)");
235
+ } else {
236
+ const filter = buildCursorFilter(cursorPts, hideWindows);
237
+ if (filter) {
238
+ const cScale = cursorCfg.scale ?? 1;
239
+ const png = resolve(tmp, "cursor.png");
240
+ await renderCursorPng(png, Math.round(32 * pxScale * cScale));
241
+ const withCursor = resolve(tmp, "content-cursor.mp4");
242
+ await runFfmpeg([
243
+ "-i",
244
+ content,
245
+ "-i",
246
+ png,
247
+ "-filter_complex",
248
+ filter,
249
+ "-map",
250
+ "[vout]",
251
+ "-an",
252
+ "-r",
253
+ String(FPS),
254
+ "-c:v",
255
+ "libx264",
256
+ "-preset",
257
+ "veryfast",
258
+ "-crf",
259
+ "20",
260
+ "-pix_fmt",
261
+ "yuv420p",
262
+ withCursor,
263
+ ]);
264
+ content = withCursor;
265
+ log(
266
+ `cursor overlay: ${cursorPts.length} path point(s)` +
267
+ (hideWindows.length ? `, ${hideWindows.length} scene(s) hidden` : "") +
268
+ (cScale !== 1 ? `, scale ${cScale}` : "")
269
+ );
270
+ }
271
+ }
272
+ }
273
+
274
+ // Auto-zoom pass over the content (never over the cards or captions).
275
+ if (zoomCfg && zoomEvents.length > 0) {
276
+ const contentMs = await probeDurationMs(content);
277
+ const filter = buildZoomFilter(zoomEvents, zoomCfg, outW, outH, contentMs, FPS);
278
+ if (filter) {
279
+ log(`auto-zoom: ${zoomEvents.length} focus event(s)`);
280
+ const zoomed = resolve(tmp, "content-zoomed.mp4");
281
+ await runFfmpeg([
282
+ "-i",
283
+ content,
284
+ "-vf",
285
+ filter,
286
+ "-an",
287
+ "-c:v",
288
+ "libx264",
289
+ "-preset",
290
+ "veryfast",
291
+ "-crf",
292
+ "20",
293
+ "-pix_fmt",
294
+ "yuv420p",
295
+ zoomed,
296
+ ]);
297
+ content = zoomed;
298
+ }
299
+ }
300
+
301
+ // Motion blur (opt-in `motionBlur` block). Average a small sliding window of
302
+ // frames so fast motion — cursor glides, eased scrolls, zoom pans — trails
303
+ // subtly; static UI is untouched (identical frames average to themselves).
304
+ // Over the content only (not cards/captions). Portable: `tmix` is baseline.
305
+ if (storyboard.motionBlur && storyboard.motionBlur.enabled !== false) {
306
+ const frames = storyboard.motionBlur.frames ?? 3;
307
+ const blurred = resolve(tmp, "content-blur.mp4");
308
+ await runFfmpeg([
309
+ "-i",
310
+ content,
311
+ "-vf",
312
+ `tmix=frames=${frames}`,
313
+ "-an",
314
+ "-r",
315
+ String(FPS),
316
+ "-c:v",
317
+ "libx264",
318
+ "-preset",
319
+ "veryfast",
320
+ "-crf",
321
+ "20",
322
+ "-pix_fmt",
323
+ "yuv420p",
324
+ blurred,
325
+ ]);
326
+ content = blurred;
327
+ log(`motion blur: tmix ${frames} frames`);
328
+ }
329
+
330
+ // Intro/outro title cards. Music runs under them; narration and captions
331
+ // shift right by the intro's length.
332
+ const segments: string[] = [];
333
+ const introMs = storyboard.intro ? storyboard.intro.durationMs : 0;
334
+ if (storyboard.intro) {
335
+ segments.push(
336
+ await cardSegment(storyboard.intro, "intro", tmp, storyboard, outW, outH, pxScale)
337
+ );
338
+ }
339
+ segments.push(content);
340
+ if (storyboard.outro) {
341
+ segments.push(
342
+ await cardSegment(storyboard.outro, "outro", tmp, storyboard, outW, outH, pxScale)
343
+ );
344
+ }
345
+ // Re-encode when assembling cards: the card segments carry mp4 edit lists
346
+ // (B-frame pts offsets), and a stream-copy concat of those yields NEGATIVE
347
+ // leading timestamps — which silently breaks the caption overlay downstream
348
+ // (framesync stops repeating the single-frame PNG inputs). A fresh encode
349
+ // regenerates clean pts from 0.
350
+ const fullVideo =
351
+ segments.length === 1
352
+ ? content
353
+ : await concatSegments(segments, resolve(tmp, "video.mp4"), tmp, "full", true);
354
+
355
+ const captioned = await burnCaptions(
356
+ project,
357
+ storyboard,
358
+ fullVideo,
359
+ tmp,
360
+ introMs,
361
+ pxScale
362
+ );
363
+ // Optional final resize/reframe (opt-in). Applied last, over the whole
364
+ // composed frame (cards + captions included), so a 1280x720 take can render
365
+ // as a vertical social clip. Audio is muxed after, so mux stays -c:v copy.
366
+ // `output` may carry only a `loudness` override (no width/height) — resize
367
+ // just when both dimensions are set.
368
+ const finalVideo =
369
+ storyboard.output?.width != null && storyboard.output?.height != null
370
+ ? await resizeOutput(captioned, storyboard.output, tmp)
371
+ : captioned;
372
+ await muxAudio(project, storyboard, finalVideo, introMs);
373
+ ok(`final video → ${project.outputPath}`);
374
+ // AIDEMO_KEEP_TMP=1 keeps .compose-tmp for debugging intermediates.
375
+ if (!process.env.AIDEMO_KEEP_TMP) {
376
+ await fs.rm(tmp, { recursive: true, force: true });
377
+ }
378
+ }
379
+
380
+ /**
381
+ * A re-voice after captions were generated leaves the caption files stale —
382
+ * compose would silently burn OLD caption text over the NEW audio, and the
383
+ * mismatch only shows up in review (bit us on maxfit-chatgpt-v3). Whisper
384
+ * needs an API key so compose can't regenerate them itself; warn loudly and
385
+ * name the fix instead.
386
+ */
387
+ async function warnStaleCaptions(project: Project): Promise<void> {
388
+ try {
389
+ const [cap, narr] = await Promise.all([
390
+ fs.stat(project.captionsCuesPath),
391
+ fs.stat(project.narrationPath),
392
+ ]);
393
+ if (cap.mtimeMs < narr.mtimeMs) {
394
+ log(
395
+ `⚠ STALE CAPTIONS: narration.mp3 is newer than the caption files — ` +
396
+ `the burned captions will not match the audio. ` +
397
+ `Fix: aidemo captions ${project.dir}` + ` (then re-run compose).`
398
+ );
399
+ }
400
+ } catch {
401
+ /* captions or narration absent — burnCaptions handles missing captions */
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Concat same-codec segments via the concat demuxer. Stream copy by default;
407
+ * pass reencode=true to regenerate timestamps (needed when segments carry
408
+ * edit-list pts offsets, e.g. the title cards).
409
+ */
410
+ async function concatSegments(
411
+ parts: string[],
412
+ outPath: string,
413
+ tmp: string,
414
+ name: string,
415
+ reencode = false
416
+ ): Promise<string> {
417
+ if (parts.length === 1) return parts[0];
418
+ const listPath = resolve(tmp, `${name}.txt`);
419
+ await fs.writeFile(
420
+ listPath,
421
+ parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n") + "\n"
422
+ );
423
+ const codecArgs = reencode
424
+ ? ["-r", String(FPS), "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p"]
425
+ : ["-c", "copy"];
426
+ await runFfmpeg([
427
+ "-f",
428
+ "concat",
429
+ "-safe",
430
+ "0",
431
+ "-i",
432
+ listPath,
433
+ "-an",
434
+ ...codecArgs,
435
+ outPath,
436
+ ]);
437
+ return outPath;
438
+ }
439
+
440
+ /**
441
+ * Crossfade the scene segments into one clip WITHOUT shrinking the timeline.
442
+ *
443
+ * A plain xfade overlaps its two inputs and so shortens the result by the fade
444
+ * length per boundary — which would slide every later scene ahead of its
445
+ * (fixed, continuous) narration. Instead we steal the overlap from each
446
+ * scene's OWN frozen tail: every non-last segment is clone-padded past its end,
447
+ * and each xfade offset is the cumulative *real* scene duration, so scene k's
448
+ * content still begins at exactly sum(d_0..d_{k-1}) — its narration slot. The
449
+ * final total equals the plain-concat total (the last segment is left un-
450
+ * padded). Net effect: identical A/V duration + alignment as hard cuts, but the
451
+ * boundaries cross-dissolve. Re-encodes (xfade needs filter_complex).
452
+ */
453
+ async function crossfadeScenes(
454
+ sceneVideos: string[],
455
+ durationMs: number,
456
+ outPath: string
457
+ ): Promise<string> {
458
+ const D = Math.max(0.05, durationMs / 1000);
459
+ // Measure each segment so the offsets track real (not assumed) durations.
460
+ const durs = await Promise.all(sceneVideos.map((p) => probeDurationMs(p)));
461
+ // Freeze-tail margin: a touch longer than D so the fade never starves at the
462
+ // boundary. The excess past offset+D is discarded by xfade (harmless).
463
+ const extendSec = (D + 0.25).toFixed(3);
464
+
465
+ const inputs: string[] = [];
466
+ for (const p of sceneVideos) inputs.push("-i", p);
467
+
468
+ const filters: string[] = [];
469
+ const labels: string[] = [];
470
+ sceneVideos.forEach((_, i) => {
471
+ const last = i === sceneVideos.length - 1;
472
+ // Clone-freeze the tail of every non-last segment so the crossfade overlaps
473
+ // that freeze rather than eating real content. Reset PTS for a clean join.
474
+ const pad = last
475
+ ? ""
476
+ : `tpad=stop_mode=clone:stop_duration=${extendSec},`;
477
+ filters.push(`[${i}:v]${pad}setpts=PTS-STARTPTS[e${i}]`);
478
+ labels.push(`e${i}`);
479
+ });
480
+
481
+ let prev = labels[0];
482
+ let cumMs = 0;
483
+ for (let i = 1; i < sceneVideos.length; i++) {
484
+ cumMs += durs[i - 1];
485
+ const offset = (cumMs / 1000).toFixed(3);
486
+ const out = i === sceneVideos.length - 1 ? "xout" : `x${i}`;
487
+ filters.push(
488
+ `[${prev}][${labels[i]}]xfade=transition=fade:duration=${D.toFixed(
489
+ 3
490
+ )}:offset=${offset}[${out}]`
491
+ );
492
+ prev = out;
493
+ }
494
+
495
+ await runFfmpeg([
496
+ ...inputs,
497
+ "-filter_complex",
498
+ filters.join(";"),
499
+ "-map",
500
+ "[xout]",
501
+ "-an",
502
+ "-r",
503
+ String(FPS),
504
+ "-c:v",
505
+ "libx264",
506
+ "-preset",
507
+ "veryfast",
508
+ "-crf",
509
+ "20",
510
+ "-pix_fmt",
511
+ "yuv420p",
512
+ outPath,
513
+ ]);
514
+ return outPath;
515
+ }
516
+
517
+ /**
518
+ * Reframe the finished (captioned) video to a different size/aspect — the
519
+ * opt-in `output` block. "contain" scales to fit and pads the remainder
520
+ * (letterbox bars in `background`); "cover" scales to fill and center-crops.
521
+ * Core scale/pad/crop only (portable). This seeds the vertical "social clip"
522
+ * lane: e.g. 720x1280 / 1080x1920 from a 1280x720 take.
523
+ */
524
+ async function resizeOutput(
525
+ video: string,
526
+ output: Output,
527
+ tmp: string
528
+ ): Promise<string> {
529
+ const { width: W, height: H } = output;
530
+ const fit = output.fit ?? "contain";
531
+ const bg = output.background ?? "black";
532
+ const vf =
533
+ fit === "cover"
534
+ ? `scale=${W}:${H}:force_original_aspect_ratio=increase:flags=lanczos,` +
535
+ `crop=${W}:${H},setsar=1`
536
+ : `scale=${W}:${H}:force_original_aspect_ratio=decrease:flags=lanczos,` +
537
+ `pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:color=${bg},setsar=1`;
538
+ const out = resolve(tmp, "resized.mp4");
539
+ await runFfmpeg([
540
+ "-i",
541
+ video,
542
+ "-vf",
543
+ vf,
544
+ "-an",
545
+ "-r",
546
+ String(FPS),
547
+ "-c:v",
548
+ "libx264",
549
+ "-preset",
550
+ "veryfast",
551
+ "-crf",
552
+ "20",
553
+ "-pix_fmt",
554
+ "yuv420p",
555
+ out,
556
+ ]);
557
+ log(`output resize -> ${W}x${H} (${fit})`);
558
+ return out;
559
+ }
560
+
561
+ /** Render a title card PNG and encode it as a fade-in/out video segment. */
562
+ async function cardSegment(
563
+ card: Card,
564
+ name: string,
565
+ tmp: string,
566
+ storyboard: Storyboard,
567
+ outW: number,
568
+ outH: number,
569
+ pxScale: number
570
+ ): Promise<string> {
571
+ const png = resolve(tmp, `${name}.png`);
572
+ await renderCardPng(
573
+ card,
574
+ png,
575
+ storyboard.video.width,
576
+ storyboard.video.height,
577
+ pxScale
578
+ );
579
+ const durSec = card.durationMs / 1000;
580
+ const fadeSec = Math.min(card.fadeMs / 1000, durSec / 3);
581
+ const out = resolve(tmp, `${name}.mp4`);
582
+ await runFfmpeg([
583
+ "-loop",
584
+ "1",
585
+ "-framerate",
586
+ String(FPS),
587
+ "-i",
588
+ png,
589
+ "-t",
590
+ durSec.toFixed(3),
591
+ "-vf",
592
+ `scale=${outW}:${outH}:flags=lanczos,setsar=1,` +
593
+ `fade=t=in:st=0:d=${fadeSec.toFixed(3)},` +
594
+ `fade=t=out:st=${(durSec - fadeSec).toFixed(3)}:d=${fadeSec.toFixed(3)}`,
595
+ "-r",
596
+ String(FPS),
597
+ "-an",
598
+ "-c:v",
599
+ "libx264",
600
+ "-preset",
601
+ "veryfast",
602
+ "-crf",
603
+ "20",
604
+ "-pix_fmt",
605
+ "yuv420p",
606
+ out,
607
+ ]);
608
+ log(`${name} card: "${card.title}" (${card.durationMs}ms)`);
609
+ return out;
610
+ }
611
+
612
+ /**
613
+ * Build the list of [startMs,endMs] video-time spans to keep for a scene:
614
+ * non-idle content in full, each idle span capped to IDLE_CAP_MS.
615
+ */
616
+ function keepIntervals(
617
+ tl: Timeline["scenes"][number],
618
+ leadInMs: number
619
+ ): Array<[number, number]> {
620
+ const vStart = tl.startMs + leadInMs;
621
+ const vEnd = tl.endMs + leadInMs;
622
+ const idles: IdleSpan[] = [...tl.idleSpans]
623
+ .map((s) => ({ ...s, startMs: s.startMs + leadInMs, endMs: s.endMs + leadInMs }))
624
+ .sort((a, b) => a.startMs - b.startMs);
625
+
626
+ const keeps: Array<[number, number]> = [];
627
+ let cursor = vStart;
628
+ for (const idle of idles) {
629
+ const s = Math.max(idle.startMs, cursor);
630
+ const e = Math.min(idle.endMs, vEnd);
631
+ if (s <= cursor) {
632
+ // idle starts at/behind cursor: keep only the capped sliver
633
+ } else {
634
+ keeps.push([cursor, s]); // non-idle before the idle
635
+ }
636
+ if (e > s) keeps.push([s, Math.min(e, s + IDLE_CAP_MS)]); // capped idle
637
+ cursor = Math.max(cursor, idle.endMs);
638
+ }
639
+ if (cursor < vEnd) keeps.push([cursor, vEnd]);
640
+ // Drop empty/negative spans.
641
+ return keeps.filter(([a, b]) => b - a > 20);
642
+ }
643
+
644
+ /**
645
+ * Where a raw-video timestamp lands inside the concatenation of the kept
646
+ * spans. Timestamps inside a trimmed gap clamp to the gap's start.
647
+ */
648
+ function offsetInKeeps(keeps: Array<[number, number]>, t: number): number {
649
+ let off = 0;
650
+ for (const [a, b] of keeps) {
651
+ if (t < a) return off;
652
+ if (t <= b) return off + (t - a);
653
+ off += b - a;
654
+ }
655
+ return off;
656
+ }
657
+
658
+ /** Extract each keep span from raw and concat into one mp4 (uniform h264). */
659
+ async function extractAndConcat(
660
+ rawVideo: string,
661
+ keeps: Array<[number, number]>,
662
+ outPath: string,
663
+ tmp: string,
664
+ sceneIdx: number
665
+ ): Promise<void> {
666
+ const parts: string[] = [];
667
+ for (let k = 0; k < keeps.length; k++) {
668
+ const [aMs, bMs] = keeps[k];
669
+ const part = resolve(tmp, `s${sceneIdx}-k${k}.mp4`);
670
+ await runFfmpeg([
671
+ "-ss",
672
+ (aMs / 1000).toFixed(3),
673
+ "-i",
674
+ rawVideo,
675
+ "-t",
676
+ ((bMs - aMs) / 1000).toFixed(3),
677
+ "-an",
678
+ "-r",
679
+ String(FPS),
680
+ "-c:v",
681
+ "libx264",
682
+ "-preset",
683
+ "veryfast",
684
+ "-crf",
685
+ "20",
686
+ "-pix_fmt",
687
+ "yuv420p",
688
+ part,
689
+ ]);
690
+ parts.push(part);
691
+ }
692
+ if (parts.length === 1) {
693
+ await fs.rename(parts[0], outPath);
694
+ return;
695
+ }
696
+ const listPath = resolve(tmp, `s${sceneIdx}-list.txt`);
697
+ await fs.writeFile(
698
+ listPath,
699
+ parts.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n") + "\n"
700
+ );
701
+ await runFfmpeg([
702
+ "-f",
703
+ "concat",
704
+ "-safe",
705
+ "0",
706
+ "-i",
707
+ listPath,
708
+ "-c",
709
+ "copy",
710
+ outPath,
711
+ ]);
712
+ }
713
+
714
+ /**
715
+ * Overlay time-gated caption PNGs onto the silent video. Returns the captioned
716
+ * video path (or the input unchanged if there are no cues). This avoids the
717
+ * libass/drawtext ffmpeg filters, which many builds lack; `overlay` is universal.
718
+ */
719
+ /**
720
+ * Pixels (at logical/storyboard scale) to lift the caption strip off the very
721
+ * bottom of the frame, so the caption pill clears the app's input bar (e.g.
722
+ * the ChatGPT composer at the bottom) instead of overlapping the prompt.
723
+ */
724
+ const CAPTION_BOTTOM_GAP = 96;
725
+
726
+ async function burnCaptions(
727
+ project: Project,
728
+ storyboard: Storyboard,
729
+ silentVideo: string,
730
+ tmp: string,
731
+ introMs: number,
732
+ pxScale: number
733
+ ): Promise<string> {
734
+ if (!(await exists(project.captionsCuesPath))) return silentVideo;
735
+ const cues = (await readJson<Cue[]>(project.captionsCuesPath)) ?? [];
736
+ if (cues.length === 0) return silentVideo;
737
+
738
+ log(`rendering ${cues.length} caption image(s)`);
739
+ const rendered = await renderCaptionPngs(
740
+ cues,
741
+ resolve(tmp, "captions"),
742
+ storyboard.video.width,
743
+ pxScale
744
+ );
745
+
746
+ const args: string[] = ["-i", silentVideo];
747
+ for (const r of rendered) args.push("-i", r.png);
748
+
749
+ // Chain one overlay per cue, each enabled only during its time window.
750
+ // Cue times are narration-relative; the intro card shifts them right.
751
+ const gap = Math.round(CAPTION_BOTTOM_GAP * pxScale);
752
+ const chain: string[] = [];
753
+ let prev = "0:v";
754
+ rendered.forEach((r, i) => {
755
+ const inp = `${i + 1}:v`;
756
+ const out = i === rendered.length - 1 ? "vout" : `o${i}`;
757
+ const a = ((r.startMs + introMs) / 1000).toFixed(3);
758
+ const b = ((r.endMs + introMs) / 1000).toFixed(3);
759
+ // Escape the enable-expression commas so ffmpeg doesn't read them as filter
760
+ // separators. Lift the strip clear of the app's bottom input bar (the
761
+ // ChatGPT composer) so captions never sit on top of the prompt being typed.
762
+ chain.push(
763
+ `[${prev}][${inp}]overlay=0:H-h-${gap}:enable=between(t\\,${a}\\,${b})[${out}]`
764
+ );
765
+ prev = out;
766
+ });
767
+
768
+ const out = resolve(tmp, "captioned.mp4");
769
+ await runFfmpeg([
770
+ ...args,
771
+ "-filter_complex",
772
+ chain.join(";"),
773
+ "-map",
774
+ "[vout]",
775
+ "-an",
776
+ "-c:v",
777
+ "libx264",
778
+ "-preset",
779
+ "veryfast",
780
+ "-crf",
781
+ "20",
782
+ "-pix_fmt",
783
+ "yuv420p",
784
+ out,
785
+ ]);
786
+ return out;
787
+ }
788
+
789
+ /**
790
+ * Mux the (captioned) video with narration and the optional music bed.
791
+ *
792
+ * Music ducking is dynamic by default: the narration keys a sidechain
793
+ * compressor on the music, so the bed dips under speech and breathes back up
794
+ * in pauses and over the intro/outro cards ("constant" mode keeps the legacy
795
+ * fixed -22dB bed). The bed is trimmed to the video, faded out at the end,
796
+ * and the narration is delayed past the intro card.
797
+ */
798
+ async function muxAudio(
799
+ project: Project,
800
+ storyboard: Storyboard,
801
+ video: string,
802
+ introMs: number
803
+ ): Promise<void> {
804
+ let musicPath = await resolveMusic(project, storyboard);
805
+ const videoMs = await probeDurationMs(video);
806
+ const durSec = (videoMs / 1000).toFixed(3);
807
+
808
+ // Loop the music enough times to cover the video, but FINITELY — an
809
+ // infinite -stream_loop never EOFs through atrim/amix, so ffmpeg would
810
+ // keep transcoding forever instead of stopping at -t.
811
+ let musicLoops = 0;
812
+ if (musicPath) {
813
+ const musicMs = await probeDurationMs(musicPath).catch(() => 0);
814
+ if (musicMs <= 0) {
815
+ log(`music track unreadable at ${musicPath}; skipping background music`);
816
+ musicPath = null;
817
+ } else {
818
+ musicLoops = Math.max(0, Math.ceil(videoMs / musicMs) - 1);
819
+ }
820
+ }
821
+
822
+ // Inputs, fixed order: 0=video, 1=narration, 2=music (if any).
823
+ const args: string[] = ["-i", video, "-i", project.narrationPath];
824
+ if (musicPath) args.push("-stream_loop", String(musicLoops), "-i", musicPath);
825
+
826
+ const delay = introMs > 0 ? `,adelay=${Math.round(introMs)}:all=1` : "";
827
+ const filters: string[] = [];
828
+
829
+ // Loudness normalization runs LAST over the muxed audio. Default-ON only when
830
+ // music is actually in the mix (the amix bed drops the master to ~-29 LUFS);
831
+ // an explicit `output.loudness` object forces it on (even narration-only) and
832
+ // `false` disables it. When it's off, the mix terminates directly in [aout]
833
+ // and the narration-only path is byte-for-byte the pre-loudness behavior.
834
+ const loudness = resolveLoudness(storyboard, !!musicPath);
835
+ const mixOut = loudness ? "amixed" : "aout";
836
+
837
+ if (musicPath) {
838
+ const m = storyboard.music!; // musicPath implies storyboard.music.track
839
+ const ducking = m.ducking ?? "sidechain";
840
+ const fadeMs = m.fadeOutMs ?? 1800;
841
+ const fadeStart = Math.max(0, (videoMs - fadeMs) / 1000);
842
+ const bedDb = ducking === "constant" ? (m.duckToDb ?? -22) : (m.gainDb ?? -14);
843
+ const bed =
844
+ `[2:a]atrim=0:${durSec},aresample=44100,volume=${bedDb}dB,` +
845
+ `afade=t=out:st=${fadeStart.toFixed(3)}:d=${(fadeMs / 1000).toFixed(3)}[mbed]`;
846
+
847
+ // NOTE: every apad uses whole_dur so the filtergraph is FINITE. An
848
+ // unbounded apad never EOFs, and `-t` alone doesn't stop ffmpeg from
849
+ // encoding the endless silence — it would run (and grow the file) forever.
850
+ // amix uses normalize=0: the default normalize=1 divides every input by the
851
+ // input count (2), silently attenuating the NARRATION by ~6 dB — the root of
852
+ // the too-quiet master. normalize=0 keeps each input at its authored level;
853
+ // the final loudnorm sets the absolute master level.
854
+ if (ducking === "sidechain") {
855
+ const threshold = m.duckThreshold ?? 0.02;
856
+ const ratio = m.duckRatio ?? 8;
857
+ const attack = m.duckAttackMs ?? 150;
858
+ const release = m.duckReleaseMs ?? 600;
859
+ filters.push(
860
+ // Pad the sidechain key with silence so the compressor releases (and
861
+ // the bed swells) over the intro/outro instead of ending early.
862
+ `[1:a]aresample=44100${delay},asplit=2[nar][k0]`,
863
+ `[k0]apad=whole_dur=${durSec}[sckey]`,
864
+ bed,
865
+ `[mbed][sckey]sidechaincompress=threshold=${threshold}:ratio=${ratio}:attack=${attack}:release=${release}[mduck]`,
866
+ `[nar][mduck]amix=inputs=2:normalize=0:duration=longest:dropout_transition=2,apad=whole_dur=${durSec}[${mixOut}]`
867
+ );
868
+ } else {
869
+ filters.push(
870
+ `[1:a]aresample=44100${delay}[nar]`,
871
+ bed,
872
+ `[nar][mbed]amix=inputs=2:normalize=0:duration=longest:dropout_transition=2,apad=whole_dur=${durSec}[${mixOut}]`
873
+ );
874
+ }
875
+ } else {
876
+ filters.push(`[1:a]aresample=44100${delay},apad=whole_dur=${durSec}[${mixOut}]`);
877
+ }
878
+
879
+ // Final loudnorm to a fixed master target. Single-pass loudnorm resamples
880
+ // internally to 192 kHz, so pin the rate back to 44100 (the pipeline's
881
+ // canonical audio rate) — otherwise the muxed AAC balloons.
882
+ if (loudness) {
883
+ filters.push(
884
+ `[amixed]loudnorm=I=${loudness.integrated}:TP=${loudness.truePeak}:LRA=${loudness.lra},` +
885
+ `aresample=44100[aout]`
886
+ );
887
+ log(
888
+ `loudness: loudnorm I=${loudness.integrated} TP=${loudness.truePeak} ` +
889
+ `LRA=${loudness.lra}${musicPath ? "" : " (narration-only override)"}`
890
+ );
891
+ }
892
+
893
+ args.push("-filter_complex", filters.join(";"));
894
+ args.push("-map", "0:v", "-map", "[aout]");
895
+ args.push(
896
+ "-c:v",
897
+ "copy",
898
+ "-c:a",
899
+ "aac",
900
+ "-b:a",
901
+ "192k",
902
+ "-t",
903
+ durSec,
904
+ project.outputPath
905
+ );
906
+ await runFfmpeg(args);
907
+ }
908
+
909
+ /**
910
+ * Decide the final master-loudness target. Off (null) by default so a plain
911
+ * narration render stays byte-for-byte unchanged; on when a `music` block is in
912
+ * the mix (music otherwise leaves the master ~-29 LUFS) OR when the storyboard
913
+ * sets an explicit `output.loudness` object. `output.loudness: false` forces it
914
+ * off even with music. Explicit partial objects have already been filled from
915
+ * LoudnessSchema defaults by zod.
916
+ */
917
+ function resolveLoudness(
918
+ storyboard: Storyboard,
919
+ hasMusic: boolean
920
+ ): Loudness | null {
921
+ const l = storyboard.output?.loudness;
922
+ if (l === false) return null; // explicitly disabled
923
+ if (l) return l; // explicit targets (defaults already applied)
924
+ return hasMusic ? LoudnessSchema.parse({}) : null; // default-on only with music
925
+ }
926
+
927
+ async function resolveMusic(
928
+ project: Project,
929
+ storyboard: Storyboard
930
+ ): Promise<string | null> {
931
+ const track = storyboard.music?.track;
932
+ if (!track) return null;
933
+ const p = isAbsolute(track) ? track : project.p(track);
934
+ if (await exists(p)) return p;
935
+ log(`music track not found at ${p}; skipping background music`);
936
+ return null;
937
+ }