feat: Hero-Video beim Upload automatisch fuers Web komprimieren (ffmpeg)
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:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-12 00:30:42 +02:00
1 parent 1acd8ed4b9
commit 8ddf781d9b
7 files changed
+301 -28

No files matched your search

+35 -21
View File
@@ -4,12 +4,8 @@ import path from "node:path";
import { isTrustedOrigin } from "@/lib/csrf";
import { rateLimit } from "@/lib/rate-limit";
import { logAdminAction } from "@/lib/audit-log";
import {
UPLOAD_DIR,
MAX_UPLOAD_VIDEO_BYTES,
detectVideoExtension,
generateUploadFilename,
} from "@/lib/uploads";
import { UPLOAD_DIR, MAX_UPLOAD_VIDEO_BYTES, detectVideoExtension, generateUploadFilename } from "@/lib/uploads";
import { transcodeVideoForWeb } from "@/lib/video-transcode";
import { requireAdminForApi } from "../_shared";
// node:fs braucht die Node-Runtime (nicht Edge).
@@ -19,16 +15,21 @@ export const runtime = "nodejs";
* Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, ein
* Video wird deshalb NICHT neu enkodiert (kein Pixel-fuer-Pixel-Rebuild wie
* bei Bildern moeglich), sondern nur anhand seiner Magic-Bytes als MP4/WebM
* verifiziert und unveraendert gespeichert. Das ist vertretbar, weil (1) nur
* eingeloggte 2FA-Admins diesen Endpunkt erreichen (requireAdminForApi),
* (2) ausgelieferte Dateien einen festen, aus der Endung abgeleiteten
* Content-Type + "X-Content-Type-Options: nosniff" bekommen (siehe
* app/uploads/[...path]/route.ts) und daher nie als HTML/Skript interpretiert
* werden koennen, und (3) der zufaellige Dateiname + die feste Endungs-
* Allowlist jede andere Nutzung des Pfads verhindern.
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, hier
* uebernimmt ffmpeg (siehe lib/video-transcode.ts transcodeVideoForWeb) die
* Neuenkodierung. Ablauf: (1) Magic-Bytes bestaetigen MP4/WebM (nur
* Format-Erkennung, sagt noch nichts ueber vollstaendige Dekodierbarkeit),
* (2) ffmpeg enkodiert das Video zu einem web-optimierten MP4 neu (kein Ton,
* begrenzte Aufloesung/Bitrate, "+faststart" fuer sofortigen Wiedergabestart
* ohne die ganze Datei laden zu muessen — das war der Hauptgrund fuer lange
* Ladezeiten bei einem direkt von Handy/Kamera hochgeladenen Rohvideo), (3)
* NUR das transkodierte Ergebnis wird gespeichert, nie die Originaldatei —
* ein erfolgreicher Transcode validiert nebenbei vollstaendig, dass die
* Datei tatsaechlich als Video dekodierbar ist (kein reiner Magic-Byte-
* Vertrauensvorschuss mehr, analog zu reencodeImage() fuer Bilder). Zugriff
* ohnehin nur fuer eingeloggte 2FA-Admins (requireAdminForApi), Auslieferung
* mit festem Content-Type + "X-Content-Type-Options: nosniff" (siehe
* app/uploads/[...path]/route.ts) und zufaelligem Dateinamen.
*/
export async function POST(request: Request) {
if (!isTrustedOrigin(request)) {
@@ -65,18 +66,31 @@ export async function POST(request: Request) {
const buffer = Buffer.from(await file.arrayBuffer());
const extension = detectVideoExtension(buffer);
if (!extension) {
if (!detectVideoExtension(buffer)) {
return NextResponse.json(
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
{ status: 400 }
);
}
const filename = generateUploadFilename(extension);
let transcoded: Buffer;
try {
transcoded = await transcodeVideoForWeb(buffer);
} catch (error: unknown) {
console.error("Video-Upload: Transcoding fehlgeschlagen.", error);
return NextResponse.json(
{ error: "Das Video konnte nicht verarbeitet werden." },
{ status: 400 }
);
}
// Ausgabe von transcodeVideoForWeb ist immer MP4 (H.264), unabhaengig vom
// Eingabeformat (MP4 ODER WebM) — deshalb hier fest "mp4" statt der
// urspruenglich erkannten Eingabe-Endung.
const filename = generateUploadFilename("mp4");
try {
await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(path.join(UPLOAD_DIR, filename), buffer);
await writeFile(path.join(UPLOAD_DIR, filename), transcoded);
} catch (error: unknown) {
// Siehe Kommentar in app/api/admin/upload/route.ts (identisches Muster).
console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error);
@@ -90,7 +104,7 @@ export async function POST(request: Request) {
action: "media.upload",
entityType: "Upload",
entityId: filename,
detail: `${extension}, ${Math.ceil(buffer.length / 1024)} KB`,
detail: `mp4, ${Math.ceil(buffer.length / 1024)} KB -> ${Math.ceil(transcoded.length / 1024)} KB`,
});
return NextResponse.json({ path: `/uploads/${filename}` });
+6 -5
View File
@@ -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;
+108
View File
@@ -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}`));
});
});
}