feat: Hero-Video beim Upload automatisch fuers Web komprimieren (ffmpeg)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m35s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m35s
Ein direkt von Kamera/Handy hochgeladenes Video (57 MB) lud spuerbar langsam: keine Kompression, kein Ton entfernt, und ohne "faststart" musste der Browser oft die gesamte Datei laden, bevor die Wiedergabe ueberhaupt beginnen konnte. Neues lib/video-transcode.ts (ffmpeg-static, per pnpm-workspace.yaml allowBuilds freigegeben) enkodiert jedes hochgeladene Video jetzt zu einem web-optimierten MP4 neu: Ton entfernt (Hero-Video ist ohnehin stumm), auf max. 1920px Breite skaliert, CRF 26 mit Bitratendeckel, "+faststart" fuer sofortigen Wiedergabestart. Nebeneffekt: ein erfolgreicher Transcode validiert das Video jetzt genauso vollstaendig wie reencodeImage() das bei Bildern schon tut (nicht mehr nur Magic-Bytes). ffmpeg-static-Binary wird in der migrator-Docker-Stage mitinstalliert (Next.js' Standalone-Tracing erkennt den Pfad hinter dem pnpm-Store-Symlink nicht zuverlaessig), lokal per npm-Postinstall ohne manuelle Einrichtung. Getestet mit synthetischem Testvideo: Transcode laeuft durch, Ausgabe enthaelt keine Audiospur mehr, ist als H.264/MP4 dekodierbar. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
1acd8ed4b9
commit
8ddf781d9b
7 files changed
+301
-28
No files matched your search
+6
-5
@@ -36,11 +36,12 @@ export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
* 100 MB: deckt auch ein laengeres oder nicht web-optimiertes Hero-Video
|
||||
* (z.B. direkt von einer Kamera/einem Handy exportiert, oft 40-80 MB) ab.
|
||||
* Grosszuegiger als MAX_UPLOAD_BYTES (Bilder), weil Video-Dateien inhaerent
|
||||
* groesser sind — Videos werden aber (anders als Bilder) NICHT neu enkodiert
|
||||
* (dafuer gibt es hier keine sharp-Entsprechung ohne eine ffmpeg-
|
||||
* Abhaengigkeit), die Groessengrenze ist daher die einzige Bremse gegen
|
||||
* uebermaessig grosse Uploads. Muss zum "proxy-body-size" in
|
||||
* k8s/06-ingress.yaml passen (dort grosszuegiger bemessen).
|
||||
* groesser sind. Diese Grenze gilt fuer die ROHDATEI vor dem Transcoding
|
||||
* (siehe lib/video-transcode.ts transcodeVideoForWeb, aufgerufen in
|
||||
* app/api/admin/upload-video/route.ts) — das tatsaechlich gespeicherte,
|
||||
* web-optimierte Ergebnis ist danach in aller Regel deutlich kleiner. Muss
|
||||
* zum "proxy-body-size" in k8s/06-ingress.yaml passen (dort grosszuegiger
|
||||
* bemessen).
|
||||
*/
|
||||
export const MAX_UPLOAD_VIDEO_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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 ffmpegPath 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> {
|
||||
if (!ffmpegPath) {
|
||||
throw new Error("ffmpeg-Binary (ffmpeg-static) nicht gefunden.");
|
||||
}
|
||||
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-"));
|
||||
const inputPath = path.join(dir, "input");
|
||||
const outputPath = path.join(dir, "output.mp4");
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await runFfmpeg(ffmpegPath, [
|
||||
"-y",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-an",
|
||||
"-vf",
|
||||
"scale='min(1920,iw)':-2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-crf",
|
||||
"26",
|
||||
"-maxrate",
|
||||
"3M",
|
||||
"-bufsize",
|
||||
"6M",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
outputPath,
|
||||
]);
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user