fix: Video-Upload haelt das Video nicht mehr mehrfach im Arbeitsspeicher
Die Route las ein bis zu 100 MB grosses Video gleich mehrfach vollstaendig in den Node-Heap: einmal aus file.arrayBuffer(), einmal als Buffer daraus, und das Transcoding-Ergebnis noch einmal aus readFile(). Zusammen mit dem Grundverbrauch des Next.js-Servers sprengt das ein Speicherlimit von 512Mi, wie es k8s/05-app-deployment.yaml bisher setzte — der Kernel beendet den Prozess dann mitten im Upload (OOMKilled). Im Browser kommt davon nur die HTML-Fehlerseite des Reverse-Proxy an, die vorher als "Unexpected token '<', "<!DOCTYPE "... is not valid JSON" erschien. Die Datei wandert jetzt streamend auf die Platte (file.stream() -> createWriteStream), ffmpeg liest und schreibt ohnehin dort, und das Ergebnis wird per rename() an seinen Platz verschoben — mit copyFile() als Rueckfallebene, weil rename() ueber Dateisystemgrenzen hinweg mit EXDEV scheitert und UPLOAD_DIR in Produktion auf einem eigenen Volume liegt. Die Formaterkennung liest nur noch die ersten 12 Bytes statt der ganzen Datei. transcodeVideoForWeb() arbeitet entsprechend auf Dateipfaden statt auf Buffern. Ausserdem faengt die Route jetzt jeden verbleibenden unerwarteten Fehler ab und antwortet auch dann mit JSON: eine durchschlagende Exception liesse Next.js eine HTML-Fehlerseite ausliefern, aus der der Client keine Meldung gewinnen kann. Verifiziert mit einem 83,8-MB-Video: 200 mit JSON-Body, Ergebnis 10,3 MB, Auslieferung inkl. Range-Anfrage (206) korrekt.
This commit is contained in:
1 parent
b11edd7751
commit
713bf48157
2 files changed
+166
-90
No files matched your search
+32
-40
@@ -1,7 +1,4 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -9,7 +6,7 @@ import ffmpegStaticPath from "ffmpeg-static";
|
||||
* (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.
|
||||
* Hintergrundvideo bei "veryfast"-Preset deutlich.
|
||||
*/
|
||||
const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -29,20 +26,23 @@ const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
* 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.
|
||||
* GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann.
|
||||
*
|
||||
* 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).
|
||||
* abdeckt (volle Neuvalidierung der Frame-Daten, nicht nur der Magic-Bytes).
|
||||
*
|
||||
* Arbeitet bewusst auf DATEIPFADEN, nicht auf Buffern: ffmpeg liest und
|
||||
* schreibt ohnehin von der Platte, und ein Hero-Video darf bis zu
|
||||
* MAX_UPLOAD_VIDEO_BYTES gross sein. Eingabe und Ergebnis zusaetzlich
|
||||
* vollstaendig im Node-Heap zu halten, kostete pro Upload ein Vielfaches
|
||||
* der Dateigroesse an Arbeitsspeicher — bei einem Container mit knappem
|
||||
* Speicherlimit reicht das aus, um den Prozess abzuschiessen (der Browser
|
||||
* sieht davon nur den Verbindungsabbruch bzw. die HTML-Fehlerseite des
|
||||
* Reverse-Proxy).
|
||||
*/
|
||||
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");
|
||||
export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise<void> {
|
||||
const args = [
|
||||
"-y",
|
||||
"-i",
|
||||
@@ -56,12 +56,11 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
|
||||
// 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.
|
||||
// Noetig, weil ein Reverse-Proxy/CDN vor einem zu lange antwortenden
|
||||
// Origin-Request selbst abbricht und eine eigene HTML-Fehlerseite
|
||||
// ausliefert -> im Client "Unexpected token '<' ... is not valid JSON",
|
||||
// weil die Antwort dann kein JSON mehr ist (siehe lib/upload-client.ts,
|
||||
// das diesen Fall inzwischen als Status meldet statt zu parsen).
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
@@ -78,27 +77,20 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
|
||||
];
|
||||
|
||||
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 });
|
||||
// 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 "pnpm 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user