112 lines
3.2 KiB
TypeScript
112 lines
3.2 KiB
TypeScript
import { spawn } from "child_process";
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
import ffmpegStatic from "ffmpeg-static";
|
|
import { getVideoAttributionText } from "../branding";
|
|
import { getResolution } from "../constants";
|
|
import { getWatermarkPath } from "../storage";
|
|
|
|
function getFfmpegPath(): string {
|
|
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
|
if (ffmpegStatic) return ffmpegStatic;
|
|
return "ffmpeg";
|
|
}
|
|
|
|
export { getFfmpegPath };
|
|
|
|
export async function encodeVideo(options: {
|
|
imagePath: string;
|
|
audioPath: string;
|
|
outputPath: string;
|
|
resolution: string;
|
|
includeWatermark: boolean;
|
|
}): Promise<void> {
|
|
const res = getResolution(options.resolution);
|
|
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
|
|
|
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
|
|
|
|
const scaleFilter = `scale=${res.width}:${res.height}:force_original_aspect_ratio=decrease,pad=${res.width}:${res.height}:(ow-iw)/2:(oh-ih)/2:black`;
|
|
|
|
const watermarkPath = getWatermarkPath();
|
|
let watermarkExists = false;
|
|
try {
|
|
await fs.access(watermarkPath);
|
|
watermarkExists = true;
|
|
} catch {
|
|
watermarkExists = false;
|
|
}
|
|
|
|
const useWatermark = options.includeWatermark && watermarkExists;
|
|
const attributionText = getVideoAttributionText().replace(/:/g, "\\:").replace(/'/g, "\\'");
|
|
const watermarkWidth = Math.max(1, Math.round(res.width * 0.32));
|
|
|
|
// Base template: ffmpeg -loop 1 -r 1 -i image -i audio -c:v libx264 -tune stillimage -c:a copy -shortest -pix_fmt yuv420p output.mp4
|
|
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
|
|
|
|
if (useWatermark) {
|
|
args.push("-i", watermarkPath);
|
|
args.push(
|
|
"-filter_complex",
|
|
`[0:v]${scaleFilter}[scaled];[2:v]scale=${watermarkWidth}:-1[wm];[scaled][wm]overlay=W-w-20:H-h-20[vout]`,
|
|
"-map",
|
|
"[vout]",
|
|
"-map",
|
|
"1:a",
|
|
);
|
|
} else if (options.includeWatermark && !watermarkExists) {
|
|
args.push(
|
|
"-filter_complex",
|
|
`[0:v]${scaleFilter},drawtext=text='${attributionText}':fontsize=22:fontcolor=white@0.85:x=w-text_w-20:y=h-th-20[vout]`,
|
|
"-map",
|
|
"[vout]",
|
|
"-map",
|
|
"1:a",
|
|
);
|
|
} else {
|
|
args.push("-vf", scaleFilter);
|
|
}
|
|
|
|
args.push(
|
|
"-c:v",
|
|
"libx264",
|
|
"-tune",
|
|
"stillimage",
|
|
"-c:a",
|
|
"copy",
|
|
"-shortest",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
options.outputPath,
|
|
);
|
|
|
|
await runFfmpeg(args);
|
|
}
|
|
|
|
function runFfmpeg(args: string[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const proc = spawn(getFfmpegPath(), args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
let stderr = "";
|
|
proc.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
proc.on("close", (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`));
|
|
});
|
|
proc.on("error", (err) => {
|
|
reject(new Error(`FFmpeg not found or failed to start: ${err.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function cleanupFiles(paths: string[]) {
|
|
for (const p of paths) {
|
|
try {
|
|
await fs.unlink(p);
|
|
} catch {
|
|
// ignore missing files
|
|
}
|
|
}
|
|
}
|