Fehlerbild "Unexpected token '<' ... is not valid JSON" nach dem ffmpeg-Fix: kein Absturz mehr, sondern vermutlich ein Timeout - das CDN/Reverse-Proxy (Cloudflare) vor der Seite bricht selbst ab, wenn der Origin-Request zu lange nicht antwortet, und liefert dafuer eine eigene HTML-Fehlerseite statt der JSON-Antwort. "medium"-Preset fuer ein 57-MB-Rohvideo konnte laenger brauchen, als das CDN wartet. "veryfast" tauscht nur Encodier-Geschwindigkeit gegen Kompressionseffizienz (nicht gegen das per -crf gesteuerte Qualitaetsziel), reduziert die Encodierzeit deutlich. Co-Authored-By: Claude Sonnet 5 <[email protected]>
132 lines
5.4 KiB
TypeScript
132 lines
5.4 KiB
TypeScript
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<Buffer> {
|
|
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<void> {
|
|
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}`));
|
|
});
|
|
});
|
|
}
|