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 { isCuratedFontKey, sanitizeFontfileForFilter, } from "../fonts"; import { resolveCuratedFontPath } from "../fonts-server"; import { buildArtTrackFilterComplex, type LayoutSettings, } from "../layout"; import { WATERMARK_WIDTH_FRACTION, watermarkFontSizeForWidth, } from "../preview-typography"; import { getWatermarkPath } from "../storage"; import { buildDrawtextFilter, normalizeWatermarkSettings, overlayXy, sanitizeDrawtext, type WatermarkSettings, } from "../watermark"; import { buildBrandWatermarkOverlayFilters } from "./brand-watermark"; function getFfmpegPath(): string { if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH; if (ffmpegStatic) return ffmpegStatic; return "ffmpeg"; } export { getFfmpegPath }; async function fileExists(p: string): Promise { try { await fs.access(p); return true; } catch { return false; } } /** Verify PNG magic bytes (prevents MIME spoof → FFmpeg surprises). */ export async function assertPngFile(filePath: string): Promise { const fh = await fs.open(filePath, "r"); try { const buf = Buffer.alloc(8); await fh.read(buf, 0, 8, 0); const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); if (!buf.equals(sig)) { throw new Error("Logo must be a valid PNG file"); } } finally { await fh.close(); } } async function resolveFontfileEscaped( settings: WatermarkSettings, ): Promise { const key = settings.fontKey ?? "system"; if (key === "system") return null; if (key === "custom") { if (!settings.fontPath) return null; if (!(await fileExists(settings.fontPath))) { throw new Error("Custom watermark font file not found"); } return sanitizeFontfileForFilter(path.resolve(settings.fontPath)); } if (isCuratedFontKey(key)) { const fontPath = resolveCuratedFontPath(key); if (!(await fileExists(fontPath))) { console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`); return null; } return sanitizeFontfileForFilter(fontPath); } return null; } function classicScaleFilter(width: number, height: number): string { return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`; } /** * Art-track filter that ends at `outLabel` instead of hardcoded [laid]. */ function artTrackFilterEndingAt( opts: Parameters[0], outLabel: string, ): string { return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`); } export async function encodeVideo(options: { imagePath: string; audioPath: string; outputPath: string; resolution: string; includeWatermark: boolean; watermark?: Partial | null; layout?: LayoutSettings | null; /** On-video song title (art-track layouts). */ songTitle?: string; artist?: string | null; }): Promise { const res = getResolution(options.resolution); if (!res) throw new Error(`Invalid resolution: ${options.resolution}`); await fs.mkdir(path.dirname(options.outputPath), { recursive: true }); const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark); const layout = options.layout?.template ? options.layout : null; const watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION)); const fontSize = watermarkFontSizeForWidth(res.width); const fontfile = await resolveFontfileEscaped(settings); const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath]; let nextInput = 2; let logoInputIndex: number | null = null; let defaultWmInputIndex: number | null = null; const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath); const defaultPath = getWatermarkPath(); const useDefaultPng = settings.mode === "default" && (await fileExists(defaultPath)); if (needsLogo && settings.logoPath) { if (!(await fileExists(settings.logoPath))) { throw new Error("Watermark logo file not found"); } await assertPngFile(settings.logoPath); args.push("-i", settings.logoPath); logoInputIndex = nextInput++; } else if (useDefaultPng) { args.push("-i", defaultPath); defaultWmInputIndex = nextInput++; } const applyWm = settings.mode === "logo" && logoInputIndex !== null ? ("logo" as const) : settings.mode === "text" && settings.text?.trim() ? ("text" as const) : settings.mode === "default" && defaultWmInputIndex !== null ? ("default-png" as const) : settings.mode === "default" ? ("default-text" as const) : ("none" as const); // Fast path: classic letterbox, no watermark if (!layout && applyWm === "none") { args.push("-vf", classicScaleFilter(res.width, res.height)); args.push( "-c:v", "libx264", "-tune", "stillimage", "-c:a", "copy", "-shortest", "-pix_fmt", "yuv420p", options.outputPath, ); await runFfmpeg(args); return; } const outDirect = applyWm === "none"; const baseLabel = outDirect ? "vout" : "base"; const filterParts: string[] = []; if (layout) { const titleEscaped = sanitizeDrawtext(options.songTitle?.trim() || "Untitled"); const artistRaw = options.artist?.trim(); const artistEscaped = artistRaw ? sanitizeDrawtext(artistRaw) : null; filterParts.push( artTrackFilterEndingAt( { width: res.width, height: res.height, layout, titleEscaped, artistEscaped, fontfileEscaped: fontfile, }, baseLabel, ), ); } else { filterParts.push(`[0:v]${classicScaleFilter(res.width, res.height)}[${baseLabel}]`); } if (applyWm === "logo" && logoInputIndex !== null) { const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY); filterParts.push( `[${logoInputIndex}:v]scale=${watermarkWidth}:-1[wm]`, `[${baseLabel}][wm]overlay=${x}:${y}[vout]`, ); } else if (applyWm === "text") { const draw = buildDrawtextFilter({ text: settings.text!.trim(), fontSize, fontColor: "white@0.9", position: settings.position, offsetX: settings.offsetX, offsetY: settings.offsetY, fontfileEscaped: fontfile, }); filterParts.push(`[${baseLabel}]${draw}[vout]`); } else if (applyWm === "default-png" && defaultWmInputIndex !== null) { filterParts.push( ...buildBrandWatermarkOverlayFilters({ baseLabel, watermarkInputIndex: defaultWmInputIndex, watermarkWidthPx: watermarkWidth, offsetX: settings.offsetX, offsetY: settings.offsetY, }), ); } else if (applyWm === "default-text") { const draw = buildDrawtextFilter({ text: getVideoAttributionText(), fontSize, fontColor: "white@0.85", position: settings.position, offsetX: settings.offsetX, offsetY: settings.offsetY, fontfileEscaped: null, }); filterParts.push(`[${baseLabel}]${draw}[vout]`); } args.push("-filter_complex", filterParts.join(";"), "-map", "[vout]", "-map", "1:a"); args.push( "-c:v", "libx264", "-tune", "stillimage", "-c:a", "copy", "-shortest", "-pix_fmt", "yuv420p", options.outputPath, ); await runFfmpeg(args); } function runFfmpeg(args: string[]): Promise { return new Promise((resolve, reject) => { const proc = spawn(getFfmpegPath(), args, { stdio: ["ignore", "pipe", "pipe"] }); let stderr = ""; proc.stderr.on("data", (chunk) => { stderr += chunk.toString(); if (stderr.length > 64_000) stderr = stderr.slice(-32_000); }); 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 } } }