import { spawn } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import ffmpegStaticPath from "ffmpeg-static"; /** * Harte Obergrenze fuer einen einzelnen ffmpeg-Lauf: verhindert, dass ein * (versehentlich oder absichtlich) pathologischer Upload einen * Server-Prozess auf unbestimmte Zeit blockiert. Admin-Uploads sind selten * (ein Hero-Video), 5 Minuten reichen fuer jedes realistische kurze * Hintergrundvideo bei "medium"-Preset deutlich. */ const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000; /** * Enkodiert ein hochgeladenes Video (MP4 ODER WebM, siehe * detectVideoExtension in lib/uploads.ts) zu einem fuer die Web-Auslieferung * optimierten MP4 (H.264/yuv420p) neu. Das ist der Grund, warum ein 57-MB- * Handy-/Kamera-Export vorher spuerbar langsam lud: * * 1. Kein Ton: das Hero-Video spielt immer stumm (siehe hero.tsx `muted`) — * die Audiospur wird komplett entfernt (-an), spart oft bereits deutlich * Groesse ohne jeden sichtbaren Qualitaetsverlust. * 2. Passende Aufloesung statt Rohmaterial: auf max. 1920px Breite skaliert * (Handy-/Kamera-Exporte sind haeufig hoeher aufgeloest, als eine * Hintergrundflaeche je zeigen kann). * 3. Web-taugliche Bitrate statt Kamera-Bitrate (CRF 26 + Bitratendeckel) * statt der oft sehr hohen Rohbitrate direkt von Kamera/Handy. * 4. "+faststart": verschiebt den moov-Atom (Inhaltsverzeichnis der Datei) * an den Dateianfang. Ohne dieses Flag muss der Browser haeufig die * GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann — * das allein erklaerte einen grossen Teil der langsamen ersten * Wiedergabe, unabhaengig von der Dateigroesse. * * Nebeneffekt: ein erfolgreicher Transcode beweist zugleich, dass die Datei * tatsaechlich vollstaendig als Video dekodierbar ist — schliesst damit die * gleiche Restluecke, die reencodeImage() (lib/uploads.ts) fuer Bilder schon * abdeckt (volle Neuvalidierung der Pixel-/Frame-Daten, nicht nur der * Magic-Bytes am Dateianfang). */ export async function transcodeVideoForWeb(buffer: Buffer): Promise { const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-")); const inputPath = path.join(dir, "input"); const outputPath = path.join(dir, "output.mp4"); const args = [ "-y", "-i", inputPath, "-an", "-vf", "scale='min(1920,iw)':-2", "-c:v", "libx264", // "veryfast" statt "medium": x264-Presets tauschen Encodier-GESCHWINDIGKEIT // gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das // steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit // "veryfast" minimal groesser, aber typischerweise 3-5x schneller fertig. // Noetig, weil ein Reverse-Proxy/CDN (hier: Cloudflare) vor einem zu // lange antwortenden Origin-Request selbst abbricht und eine eigene // HTML-Fehlerseite ausliefert -> im Client "Unexpected token '<' ... // is not valid JSON", weil response.json() diese HTML-Seite nicht // parsen kann. Mit "medium" konnte ein 57-MB-Rohvideo laenger brauchen, // als das CDN auf eine Antwort wartet. "-preset", "veryfast", "-crf", "26", "-maxrate", "3M", "-bufsize", "6M", "-pix_fmt", "yuv420p", "-movflags", "+faststart", outputPath, ]; try { await writeFile(inputPath, buffer); try { // Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add // ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter // /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur. await runFfmpeg("ffmpeg", args); } catch (error: unknown) { // ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein // lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per // npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "npm run // dev" ohne manuelle Einrichtung funktioniert. Jeder andere Fehler // (z.B. eine tatsaechlich nicht dekodierbare Datei) soll dagegen // unveraendert durchschlagen, nicht stillschweigend erneut versucht // werden. const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT"; if (!isMissingBinary || !ffmpegStaticPath) throw error; await runFfmpeg(ffmpegStaticPath, args); } return await readFile(outputPath); } finally { await rm(dir, { recursive: true, force: true }); } } function runFfmpeg(binary: string, args: string[]): Promise { return new Promise((resolve, reject) => { const proc = spawn(binary, args); let stderr = ""; const timer = setTimeout(() => { proc.kill("SIGKILL"); reject(new Error("ffmpeg-Zeitlimit ueberschritten.")); }, TRANSCODE_TIMEOUT_MS); proc.stderr.on("data", (chunk: Buffer) => { // ffmpeg schreibt Fortschritt/Diagnose nach stderr auch bei Erfolg — // hier nur fuer eine hilfreiche Fehlermeldung im Misserfolgsfall // gesammelt (auf die letzten paar KB begrenzt), nicht geloggt. stderr += chunk.toString(); if (stderr.length > 4000) stderr = stderr.slice(-4000); }); proc.on("error", (err) => { clearTimeout(timer); reject(err); }); proc.on("close", (code) => { clearTimeout(timer); if (code === 0) resolve(); else reject(new Error(`ffmpeg beendete sich mit Code ${code}: ${stderr}`)); }); }); }