FFmpeg-Ausgabe mit Node.js streamen
Starten Sie FFmpeg mit Node.js, streamen Sie die codierten Bytes in eine temporäre Datei und veröffentlichen Sie das Ergebnis erst, wenn Encoding und Schreiben abgeschlossen sind. Diese Anleitung liefert einen lokalen Befehl, der ein Video in H.264 mit optionalem AAC-Audio konvertiert, bestehende Ausgabedateien nicht überschreibt und Teilergebnisse bei Fehlern oder Abbruch entfernt.
Wählen Sie, was gestreamt wird
Die Eingabe ist eine vollständige lokale Datei. FFmpeg öffnet sie direkt, um darin springen zu
können. Das ist für MP4/MOV-Dateien wichtig, deren Metadaten am Ende stehen. Die Ausgabe gelangt
über stdout in einen beschreibbaren Node.js-Stream. Sie behalten die
ursprüngliche Eingabe und eine fertige Ausgabedatei; ein Upload-Server ist nicht beteiligt.
Eine Pipe erlaubt keine Positionssprünge. Verwenden Sie für die MP4-Ausgabe
+frag_keyframe+empty_moov, um einen anfänglichen Header und nachfolgende Fragmente zu schreiben.
Normales MP4 mit +faststart benötigt stattdessen eine Ausgabedatei, die
Positionssprünge erlaubt. Die Dokumentation zum MP4-Muxer von FFmpeg
erläutert die Fragmentierung und ihre Kompatibilitätsnachteile: Manche Player und Editoren
akzeptieren kein fragmentiertes MP4. Prüfen Sie die vorgesehene Zielanwendung, bevor Sie dieses
Ausgabeformat wählen.
Streaming beschreibt hier den Transport der Bytes. Das Encoding läuft so schnell, wie es der Rechner erlaubt, und der endgültige Dateiname erscheint erst nach Abschluss. Dies ist eine Stapelkonvertierung, ohne Zusage von Encoding in Echtzeit, Live-Wiedergabe oder adaptivem Streaming.
Bereiten Sie ein lokales Beispiel vor
Verwenden Sie Linux, Node.js 24 oder 26 und einen FFmpeg-Build mit den Encodern
libx264 und aac sowie ffprobe.
Das Beispiel nutzt die integrierte TypeScript-Unterstützung von Node
und benötigt keine npm-Pakete. Installieren Sie fehlende Werkzeuge über die Seiten
Node.js-Downloads und
FFmpeg-Downloads. Die folgenden Befehle verwenden Bash und ein lokales
Dateisystem, das Hardlinks unterstützt. Beginnen Sie mit einem Video, dem Sie vertrauen; dieser
Befehl ist keine Upload-Sandbox.
Prüfen Sie Ihre Werkzeuge:
node --version &&
ffmpeg -version &&
ffprobe -version &&
ffmpeg -hide_banner -encoders
Vergewissern Sie sich, dass die Encoder-Liste libx264 und
aac enthält. Getestet wurden Node.js 26.8.1 mit FFmpeg 9.0.1 unter Linux
sowie Node.js 24.13.0 und 26.5.0 mit FFmpeg 6.1.1 unter Ubuntu 24.04.
Erstellen Sie ein neues Verzeichnis und ein dreisekündiges Beispiel mit Video und einem Ton.
Behalten Sie die Verkettung mit && bei: Falls das Verzeichnis bereits
existiert oder der Wechsel dorthin fehlschlägt, wird darin nichts geschrieben. Auch das Flag
-n verhindert, dass das Beispiel ersetzt wird.
mkdir node-ffmpeg-demo &&
cd node-ffmpeg-demo &&
printf '{"type":"module"}\n' > package.json &&
ffmpeg -nostdin -n -v error \
-f lavfi -i testsrc2=size=320x180:rate=24 \
-f lavfi -i sine=frequency=440:sample_rate=48000 \
-t 3 -c:v libx264 -threads 2 -pix_fmt yuv420p -c:a aac input.mp4
Bleiben Sie für die weiteren Befehle in diesem Verzeichnis. Das Beispiel verwendet bewusst eine
normale MP4-Datei. Wenn Sie ihren Dateinamen an FFmpeg übergeben, vermeiden Sie die Einschränkungen
bei Positionssprüngen, die beim Zuführen über pipe:0 entstehen.
Verbinden Sie FFmpeg mit einem beschreibbaren Stream
Speichern Sie das vollständige Skript unten als transcode.ts. Das Ausgabeverzeichnis
wird bei Bedarf erstellt. Eine bestehende Zieldatei bleibt erhalten, selbst wenn ein anderer
Prozess sie während des Encodings erstellt.
import { spawn } from 'node:child_process'
import { createWriteStream } from 'node:fs'
import { link, mkdir, mkdtemp, rm, stat } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { parseArgs } from 'node:util'
function diagnosticCode(error: unknown): string {
if (typeof error === 'object' && error !== null && 'code' in error) {
const code = error.code
if (
typeof code === 'string' &&
['ENOENT', 'EACCES', 'EEXIST', 'ENOSPC', 'ABORT_ERR'].includes(code)
) return code
}
return 'PROCESSING_FAILED'
}
function logCleanupFailure(error: unknown): void {
console.error('Temporary file cleanup failed', { code: diagnosticCode(error) })
}
async function withOutputFile(
outputPath: string,
signal: AbortSignal,
produce: (temporary: string) => Promise<void>,
): Promise<string> {
signal.throwIfAborted()
const destination = resolve(outputPath)
await mkdir(dirname(destination), { recursive: true })
const directory = await mkdtemp(join(dirname(destination), '.ffmpeg-'))
const temporary = join(directory, 'output.mp4')
try {
signal.throwIfAborted()
await produce(temporary)
const info = await stat(temporary)
if (info.size === 0) throw new Error('FFmpeg produced an empty file')
signal.throwIfAborted()
// An exclusive hard link publishes on the same filesystem without copying or replacing data.
await link(temporary, destination)
return destination
} finally {
// A cleanup warning must not replace the original processing error or undo a published file.
await rm(directory, { recursive: true, force: true }).catch(logCleanupFailure)
}
}
async function streamTranscode(
inputPath: string,
outputPath: string,
signal: AbortSignal,
executable: string,
): Promise<string> {
return withOutputFile(outputPath, signal, async (temporary) => {
signal.throwIfAborted()
const child = spawn(executable, [
'-nostdin', '-v', 'error', '-xerror',
'-threads', '2', '-i', resolve(inputPath),
'-map', '0:v:0', '-map', '0:a:0?',
'-filter_threads', '1', '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-c:v', 'libx264', '-threads', '2', '-preset', 'medium', '-crf', '23',
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k',
'-movflags', '+frag_keyframe+empty_moov', '-f', 'mp4', 'pipe:1',
], { stdio: ['ignore', 'pipe', 'pipe'] })
let diagnostics = ''
let processError: Error | undefined
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => {
diagnostics = (diagnostics + chunk).slice(-8000)
})
const closed = new Promise<void>((resolveClosed, reject) => {
child.once('error', (error) => {
processError = error
})
child.once('close', (code, killedBy) => {
if (processError) reject(processError)
else if (code === 0) resolveClosed()
else reject(new Error(`FFmpeg failed (${killedBy ?? code}): ${diagnostics}`))
})
})
const io = new AbortController()
const written = pipeline(
child.stdout,
createWriteStream(temporary, { flags: 'wx' }),
{ signal: io.signal },
)
const stop = (): void => {
io.abort()
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
}
signal.addEventListener('abort', stop, { once: true })
if (signal.aborted) stop()
try {
// Neither a clean stdout EOF nor FFmpeg exiting alone proves that the job succeeded.
await Promise.all([closed, written])
signal.throwIfAborted()
} catch (error) {
stop()
await Promise.allSettled([closed, written])
signal.throwIfAborted()
throw error
} finally {
signal.removeEventListener('abort', stop)
}
})
}
let verbose = false
async function main(): Promise<void> {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
ffmpeg: { type: 'string', default: 'ffmpeg' },
timeout: { type: 'string', default: '120' },
verbose: { type: 'boolean', default: false },
},
})
verbose = values.verbose
const [input, output] = positionals
const seconds = Number(values.timeout)
if (
!input || !output || positionals.length !== 2 ||
!Number.isFinite(seconds) || seconds <= 0 || seconds > 86400
) {
throw new Error('Usage: node transcode.ts [--timeout seconds] [--verbose] -- input output.mp4')
}
const controller = new AbortController()
const stop = (): void => controller.abort(
Object.assign(new Error('Transcoding cancelled or timed out'), { code: 'ABORT_ERR' }),
)
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const timer = setTimeout(stop, seconds * 1000)
try {
const outputFile = await streamTranscode(input, output, controller.signal, values.ffmpeg)
console.log(`Transcoding completed: ${outputFile}`)
} finally {
clearTimeout(timer)
process.off('SIGINT', stop)
process.off('SIGTERM', stop)
}
}
main().catch((error: unknown) => {
console.error('Transcoding failed', { code: diagnosticCode(error) })
// Detailed diagnostics are opt-in because FFmpeg messages can include local paths and metadata.
if (verbose && error instanceof Error) console.error(error.message)
process.exitCode = 1
})
spawn() erhält ein Argument-Array und führt keine Shell aus.
pipeline() sorgt für Backpressure: Wenn der beschreibbare Stream ausgelastet ist,
liest Node keine weiteren Daten aus der FFmpeg-Ausgabe, und die Pipe zwingt FFmpeg schließlich
zum Warten. Auch Stream-Fehler werden weitergegeben. Siehe die
Dokumentation zur Stream-Pipeline von Node.
Der erste Videostream ist erforderlich. Das Zeichen ? in
0:a:0? macht den ersten Audiostream optional, wie in den
Regeln zur Stream-Zuordnung von FFmpeg beschrieben.
Padding fügt bei Bedarf eine Zeile oder Spalte hinzu, damit die H.264-Ausgabe mit
yuv420p gerade Abmessungen hat. Untertitel, zusätzliche Audiospuren und
weitere Videostreams werden weggelassen.
Für einen erfolgreichen Abschluss muss sowohl das
Ereignis close des Kindprozesses mit
Exit-Code null eintreten als auch die Schreib-Pipeline abgeschlossen sein. Ein Schreibfehler
stoppt FFmpeg; ein fehlgeschlagener FFmpeg-Prozess bricht das Schreiben ab. Der Code wartet, bis
beides beendet ist, bevor er temporäre Daten entfernt. Ein exklusiver Hardlink veröffentlicht
dann die fertige Datei ohne eine zweite vollständige Kopie. Dies setzt Hardlink-Unterstützung
voraus und gewährleistet keine Beständigkeit bei Stromausfall.
Führen Sie die Konvertierung aus und prüfen Sie das Ergebnis
Führen Sie das Beispiel aus und prüfen Sie anschließend die tatsächlich enthaltenen Streams:
node transcode.ts -- input.mp4 output.mp4 &&
ffprobe -v error -show_entries stream=codec_name,codec_type,width,height \
-show_entries format=duration -of json output.mp4
Erwarten Sie eine Abschlussmeldung mit dem absoluten Ausgabepfad. Die Analyse sollte H.264-Video
mit 320 × 180, AAC-Audio und eine Dauer von etwa drei Sekunden melden. Ein Video ohne Audio erzeugt
nur den Videostream. Verwenden Sie für Ihre eigene Eingabe Pfade in Anführungszeichen und einen
neuen Ausgabenamen, zum Beispiel node transcode.ts -- "my clip.mov" "my clip encoded.mp4".
Führen Sie denselben Befehl erneut aus, um den Überschreibschutz zu prüfen: Er endet mit Code 1
und meldet EEXIST. Die ursprüngliche Ausgabe bleibt unverändert. Die
Verweigerung erfolgt erst bei der Veröffentlichung, daher benötigt auch der zweite Durchlauf
Zeit für das Encoding. Wählen Sie einen anderen Dateinamen, um ein weiteres Ergebnis zu behalten.
Für eine herkömmliche MP4-Datei, die ein Editor öffnen kann, muxen Sie die fertige Ausgabe in eine weitere neue Datei um:
ffmpeg -nostdin -n -v error -i output.mp4 -map 0 -c copy -movflags +faststart compatible.mp4
Dabei werden die codierten Streams ohne erneutes Encoding kopiert. Der Vorgang schreibt direkt
in compatible.mp4, sodass ein unterbrochenes Ummuxen dort eine unvollständige Datei
hinterlassen kann. -n verweigert eine bestehende Zieldatei. Prüfen oder
entfernen Sie diese unvollständige Datei selbst, bevor Sie es erneut versuchen. Manche
FFmpeg-Versionen geben bei verweigertem Überschreiben den Status null zurück. Prüfen Sie daher
die Diagnosemeldung und die Datei, statt sich nur auf den Status zu verlassen. Diese zusätzliche
Datei benötigt ebenfalls Speicherplatz auf dem Datenträger.
Behandeln Sie Fehler und Ressourcenlimits
Strg+C, SIGTERM oder das standardmäßige Zeitlimit von 120 Sekunden bricht die Verarbeitung ab
und entfernt die temporäre Ausgabe. Erhöhen Sie das Zeitlimit für eine größere vertrauenswürdige
Eingabe mit --timeout 600. Sobald die Veröffentlichung begonnen hat, kann eine
fertige Zieldatei bestehen bleiben; ein Abbruch löscht sie niemals. SIGKILL, ein Rechnerabsturz
oder fehlende Berechtigungen beim Aufräumen können ein Verzeichnis namens
.ffmpeg-* zur manuellen Prüfung hinterlassen.
Die standardmäßige Fehlerzeile enthält nur einen geprüften Code. Fügen Sie zum lokalen Debuggen
--verbose hinzu; FFmpeg-Fehler enthalten höchstens die letzten 8.000 Zeichen
der Diagnoseausgabe. Leiten Sie diese ausführliche Ausgabe nicht an einen HTTP-Client weiter.
| Ergebnis | Was Sie prüfen sollten |
|---|---|
ENOENT | Die ausführbare FFmpeg-Datei wurde nicht gefunden. Verwenden Sie bei Bedarf --ffmpeg /absolute/path/to/ffmpeg. |
PROCESSING_FAILED | Prüfen Sie mit --verbose, ob die Eingabe ungültig ist oder fehlt, ein Encoder nicht verfügbar ist oder Medien nicht unterstützt werden. |
EEXIST | Wählen Sie einen neuen Namen für die Ausgabedatei. |
EACCES oder ENOSPC | Prüfen Sie die Verzeichnisberechtigungen und den verfügbaren Speicherplatz auf dem Datenträger. |
ABORT_ERR | Der Job wurde abgebrochen oder hat sein Zeitlimit überschritten. |
-xerror stoppt FFmpeg bei gemeldeten Fehlern.
Eine erfolgreiche Konvertierung beweist jedoch nicht, dass die Quelle vollständig oder sicher
ist. Manche abgeschnittenen Container enthalten weiterhin decodierbare Frames. Wenn Ihre
Anwendung eine erwartete Dauer oder Prüfsumme kennt, validieren Sie diese separat.
Dieser Befehl führt eine Konvertierung aus, begrenzt die angeforderten Codec-Threads und die aufbewahrte Diagnoseausgabe und vermeidet es, das gesamte Video in Node zwischenzuspeichern. FFmpeg benötigt weiterhin eigenen Arbeitsspeicher für Decoder und Encoder; Backpressure ist kein Arbeitsspeicher- oder CPU-Kontingent. Auch die Ausgabegröße ist nicht begrenzt. Legen Sie für eine Upload-API zunächst die Eingabe zwischen, setzen Sie Upload- und Datenträgerlimits durch und führen Sie Jobs über eine begrenzte Queue mit Ressourcenlimits des Betriebssystems aus. Die lokale Stream-Pipeline ist der Verarbeitungsschritt, kein vollständiger öffentlicher Dienst.
