mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 12:08:47 +02:00
89 lines
2.7 KiB
TypeScript
89 lines
2.7 KiB
TypeScript
import { execFile, execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { buildSrt, type Clip } from "./subtitles";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function hasFfmpeg() {
|
|
try {
|
|
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const SUBTITLE_STYLE =
|
|
"FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40";
|
|
|
|
function escapeForFilter(p: string) {
|
|
// ffmpeg filter arguments treat ':' and '\' specially — escape both.
|
|
return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
|
}
|
|
|
|
export async function renderFinalVideo({
|
|
rawWebmPath,
|
|
clips,
|
|
outDir,
|
|
}: {
|
|
rawWebmPath: string;
|
|
clips: Clip[];
|
|
outDir: string;
|
|
}): Promise<{ videoFile: string; degraded: string[] }> {
|
|
const degraded: string[] = [];
|
|
const srtPath = path.join(outDir, "captions.srt");
|
|
fs.writeFileSync(srtPath, buildSrt(clips));
|
|
|
|
if (!hasFfmpeg()) {
|
|
const dest = path.join(outDir, "video.webm");
|
|
fs.renameSync(rawWebmPath, dest);
|
|
degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration.");
|
|
return { videoFile: dest, degraded };
|
|
}
|
|
|
|
const narrated = clips.filter((c) => c.narration);
|
|
const mp4Path = path.join(outDir, "video.mp4");
|
|
const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`;
|
|
|
|
if (narrated.length === 0) {
|
|
degraded.push("No narration available — burned-in subtitles only.");
|
|
await execFileAsync("ffmpeg", [
|
|
"-y", "-i", rawWebmPath,
|
|
"-vf", srtArg,
|
|
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
|
"-movflags", "+faststart",
|
|
"-an",
|
|
mp4Path,
|
|
]);
|
|
} else {
|
|
const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]);
|
|
const adelayParts = narrated.map(
|
|
(c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`,
|
|
);
|
|
const mixInputs = narrated.map((_, i) => `[a${i}]`).join("");
|
|
const filterComplex = [
|
|
`[0:v]${srtArg}[vout]`,
|
|
...adelayParts,
|
|
`${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`,
|
|
].join(";");
|
|
|
|
await execFileAsync("ffmpeg", [
|
|
"-y", "-i", rawWebmPath, ...audioInputs,
|
|
"-filter_complex", filterComplex,
|
|
"-map", "[vout]", "-map", "[aout]",
|
|
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
|
"-c:a", "aac",
|
|
"-shortest",
|
|
"-movflags", "+faststart",
|
|
mp4Path,
|
|
]);
|
|
}
|
|
|
|
fs.rmSync(rawWebmPath, { force: true });
|
|
for (const c of narrated) fs.rmSync(c.narration!.path, { force: true });
|
|
|
|
return { videoFile: mp4Path, degraded };
|
|
}
|