@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/.claude/skills/record-demo/SKILL.md +66 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/aidemo.mjs +53 -0
- package/docs/AUTHORING.md +728 -0
- package/package.json +47 -0
- package/src/caption-render.ts +69 -0
- package/src/captions.ts +441 -0
- package/src/capture.ts +574 -0
- package/src/cards.ts +67 -0
- package/src/cli.ts +646 -0
- package/src/compose.ts +937 -0
- package/src/config.ts +222 -0
- package/src/cursor-overlay.ts +186 -0
- package/src/cursor.ts +134 -0
- package/src/distribute.ts +713 -0
- package/src/embed.ts +202 -0
- package/src/ffmpeg.ts +83 -0
- package/src/gif.ts +65 -0
- package/src/golden.ts +109 -0
- package/src/i18n.ts +86 -0
- package/src/mcp/jobs.ts +285 -0
- package/src/mcp/server.ts +1016 -0
- package/src/music.ts +153 -0
- package/src/params.ts +134 -0
- package/src/player.ts +1173 -0
- package/src/project.ts +287 -0
- package/src/recorder.ts +320 -0
- package/src/starter.ts +141 -0
- package/src/stills.ts +121 -0
- package/src/types.ts +660 -0
- package/src/util.ts +171 -0
- package/src/variants.ts +117 -0
- package/src/voice-local.ts +230 -0
- package/src/voice.ts +405 -0
- package/src/zoom.ts +183 -0
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
|
+
}
|