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
@@ -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}` });
|
||||
|
||||
Reference in new issue
Block a user