Videoverarbeitung mit Streams in Node.js und FFmpeg
Node.js und FFmpeg sind für sich genommen bereits mächtige Werkzeuge, doch in Kombination bieten sie eine robuste Lösung für die Videoverarbeitung in Echtzeit. In diesem DevTip zeigen wir, wie Sie Node.js-Streams und FFmpeg nutzen, um eine schlanke, hochperformante API zum Transkodieren von Videos zu bauen.
Warum FFmpeg mit Node.js integrieren?
FFmpeg ist ein vielseitiges Open-Source-Multimedia-Framework, das Videos transkodieren, Thumbnails extrahieren, Wasserzeichen setzen und vieles mehr erledigen kann. Node.js ergänzt FFmpeg mit seinem nicht blockierenden I/O-Modell und seinen effizienten Streaming-Fähigkeiten perfekt. Diese Kombination ermöglicht eine effiziente Videoverarbeitung in Echtzeit, ohne den Event Loop von Node.js zu blockieren, und eignet sich damit ideal für Webanwendungen und APIs.
Voraussetzungen: Node.js und FFmpeg installieren
Stellen Sie sicher, dass Node.js und FFmpeg auf Ihrem System installiert sind.
Node.js-Installation
Wir empfehlen den Node Version Manager (nvm), um Node.js-Versionen zu verwalten.
# Install nvm (node version manager)
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# Reload shell configuration (e.g., ~/.bashrc, ~/.zshrc) or restart your terminal
# Example for bash:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
# Install the latest lts Node.js version
nvm install --lts
FFmpeg-Installation
macOS:
# Install homebrew package manager if you don't have it
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install FFmpeg
brew install ffmpeg
Ubuntu/Debian:
sudo apt update
sudo apt install ffmpeg -y
Überprüfen Sie die Installationen:
node -v
ffmpeg -version
Node.js-Streams und Kindprozesse nutzen
Node.js-Streams ermöglichen den effizienten Umgang mit Datenblöcken, was ideal ist, um große
Videodateien zu verarbeiten, ohne die gesamte Datei in den Arbeitsspeicher zu laden. Mithilfe des
Moduls child_process können wir das FFmpeg-Kommandozeilenwerkzeug direkt aus
unserer Node.js-Anwendung aufrufen.
Speichern Sie dieses Beispiel als video-tools.cjs. Es teilt sich einen
Subprozess-Runner und einen Helfer für temporäre Ausgaben mit den folgenden Beispielen, sodass die
Aufräumroutine im Fehlerfall kein vorhandenes Ziel löschen kann. Verwenden Sie echte
Medien-Fixtures; eine Textdatei namens .mov ist kein gültiges Video. Die
H.264-Beispiele erwarten gerade Bildmaße für die Ausgabe mit yuv420p:
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
async function withOutputFile(outputPath, produce) {
const destination = path.resolve(outputPath)
await fs.promises.mkdir(path.dirname(destination), { recursive: true })
const directory = await fs.promises.mkdtemp(path.join(path.dirname(destination), '.ffmpeg-'))
const temporaryPath = path.join(directory, `output${path.extname(destination)}`)
try {
await produce(temporaryPath)
const info = await fs.promises.stat(temporaryPath)
if (info.size === 0) throw new Error('FFmpeg produced an empty file')
// Both paths share a filesystem; an exclusive hard link publishes without copying the video.
await fs.promises.link(temporaryPath, destination)
return destination
} finally {
await fs.promises.rm(directory, { recursive: true, force: true })
}
}
function runFFmpeg(args) {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', ['-nostdin', '-n', '-v', 'error', ...args], {
stdio: ['ignore', 'ignore', 'pipe'],
})
let diagnostics = ''
child.stderr.on('data', (chunk) => {
diagnostics = (diagnostics + chunk.toString()).slice(-8000)
})
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`FFmpeg failed (${code}): ${diagnostics}`))
})
})
}
async function transcodeVideo(inputPath, outputPath) {
return withOutputFile(outputPath, (temporaryPath) =>
runFFmpeg([
'-i',
path.resolve(inputPath),
'-map',
'0:v:0',
'-map',
'0:a:0?',
'-c:v',
'libx264',
'-preset',
'medium',
'-crf',
'23',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
temporaryPath,
]),
)
}
module.exports = { runFFmpeg, withOutputFile, transcodeVideo }
if (require.main === module) {
transcodeVideo('input.mov', path.join('processed', 'output.mp4'))
.then((output) => console.log(`Transcoding completed: ${output}`))
.catch((error) => {
console.error(error.message)
process.exitCode = 1
})
}
Die Funktion bildet den ersten Videostream und den optionalen ersten Audiostream auf H.264 und AAC ab. FFmpeg schreibt in ein neues temporäres Verzeichnis im Dateisystem des Ziels; der Helfer veröffentlicht ein fertiggestelltes, nicht leeres Ergebnis mithilfe eines exklusiven Hardlinks, ohne es zu kopieren oder eine vorhandene Datei zu überschreiben. Er entfernt bei Erfolg oder Fehlschlag ausschließlich dieses temporäre Verzeichnis.
Eine einfache API zum Transkodieren von Videos erstellen
Erstellen wir einen einfachen Express.js-API-Endpunkt, der Video-Uploads entgegennimmt und sie mit
der zuvor definierten Funktion transkodiert. Installieren Sie die Abhängigkeiten für den Upload mit
npm install express@5 multer@2. Dies ist eine lokale Demonstration: Halten Sie die zugehörigen
Verzeichnisse außerhalb des Webroots und ergänzen Sie Authentifizierung, Anfragelimits und eine
begrenzte Verarbeitungs-Queue, bevor Sie sie öffentlich zugänglich machen. Führen Sie FFmpeg beim
Verarbeiten nicht vertrauenswürdiger Medien unter einem eingeschränkten Betriebssystemkonto mit
Ressourcenlimits, ohne Netzwerkzugriff und ohne Zugriff auf Anwendungsgeheimnisse aus.
const express = require('express')
const multer = require('multer')
const { mkdir, rm } = require('node:fs/promises')
const { randomUUID } = require('node:crypto')
const path = require('node:path')
const { transcodeVideo } = require('./video-tools.cjs')
const UPLOAD_DIR = path.resolve('uploads')
const TRANSCODED_DIR = path.resolve('transcoded')
const upload = multer({
dest: UPLOAD_DIR,
limits: { fileSize: 200 * 1024 * 1024, files: 1 },
})
const app = express()
function diagnosticCode(error) {
// Never log arbitrary messages, paths, or third-party error payloads.
return ['ENOENT', 'EACCES', 'EEXIST', 'LIMIT_FILE_SIZE'].includes(error?.code)
? error.code
: 'PROCESSING_FAILED'
}
function logCleanupFailure(error) {
console.error('Temporary file cleanup failed', { code: diagnosticCode(error) })
}
app.post('/transcode', upload.single('video'), async (req, res, next) => {
if (!req.file) return res.status(400).json({ error: 'No video file uploaded.' })
const inputPath = req.file.path
const outputFilename = `${randomUUID()}.mp4`
const outputPath = path.join(TRANSCODED_DIR, outputFilename)
try {
await transcodeVideo(inputPath, outputPath)
res.download(outputPath, outputFilename, (error) => {
// Download completion is the point at which the output can be removed.
Promise.all([rm(inputPath, { force: true }), rm(outputPath, { force: true })]).catch(
logCleanupFailure,
)
if (error) next(error)
})
} catch (error) {
await rm(inputPath, { force: true }).catch(logCleanupFailure)
next(error)
}
})
app.use((error, req, res, next) => {
if (res.headersSent) return next(error)
const tooLarge = error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE'
console.error('Upload or transcoding failed', { code: diagnosticCode(error) })
res.status(tooLarge ? 413 : 422).json({
error: tooLarge ? 'Video exceeds the upload size limit.' : 'Video could not be processed.',
})
})
async function main() {
await mkdir(UPLOAD_DIR, { recursive: true, mode: 0o700 })
await mkdir(TRANSCODED_DIR, { recursive: true, mode: 0o700 })
const port = process.env.PORT || 3000
app.listen(port, () => console.log(`Server running on http://localhost:${port}`))
}
main().catch((error) => {
console.error('Server startup failed', { code: diagnosticCode(error) })
process.exitCode = 1
})
Multer begrenzt die Upload-Größe und erzeugt einen serverseitig vergebenen temporären Namen. MIME-Typen und Dateiendungen des Clients gelten nicht als Nachweis für den Inhalt. FFmpeg muss die ausgewählten Streams erfolgreich decodieren, bevor die API das Ergebnis ausliefert. Das ist dennoch kein Urteil über Schadsoftware oder Dateisicherheit.
Thumbnails extrahieren und Wasserzeichen anwenden
FFmpeg kann noch viele weitere Aufgaben übernehmen. Hier sind Funktionen zum Extrahieren eines Thumbnails und zum Anwenden eines Wasserzeichens, inklusive Fehlerbehandlung und Dateiprüfungen.
const path = require('node:path')
const { runFFmpeg, withOutputFile } = require('./video-tools.cjs')
async function extractThumbnail(inputPath, outputPath, timestamp = '00:00:01.000') {
return withOutputFile(outputPath, (temporaryPath) =>
runFFmpeg([
'-ss',
timestamp,
'-i',
path.resolve(inputPath),
'-map',
'0:v:0',
'-frames:v',
'1',
'-q:v',
'2',
'-f',
'image2',
temporaryPath,
]),
)
}
async function applyWatermark(inputPath, watermarkPath, outputPath) {
return withOutputFile(outputPath, (temporaryPath) =>
runFFmpeg([
'-i',
path.resolve(inputPath),
'-i',
path.resolve(watermarkPath),
'-filter_complex',
'[0:v:0][1:v:0]overlay=10:10[v]',
'-map',
'[v]',
'-map',
'0:a:0?',
'-c:v',
'libx264',
'-crf',
'23',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
temporaryPath,
]),
)
}
// These examples require a real input video and PNG watermark.
async function main() {
await extractThumbnail('input.mp4', path.join('processed', 'thumbnail.jpg'))
await applyWatermark('input.mp4', 'watermark.png', path.join('processed', 'watermarked.mp4'))
}
main().catch((error) => {
console.error(error.message)
process.exitCode = 1
})
Beide Funktionen verwenden den Ausgabe-Helfer erneut. Das Springen in der Eingabe vor
-i vermeidet, dass für jedes Thumbnail von Beginn an decodiert wird. Ein
Zeitstempel jenseits des Videoendes kann dazu führen, dass keine Datei entsteht, selbst wenn FFmpeg
erfolgreich beendet wird; der Helfer weist dieses Ergebnis zurück. Beim Wasserzeichen werden das
gefilterte Video und der optionale Quell-Audiostream explizit gemappt und das Audio wird für die
MP4-Kompatibilität neu codiert.
Fortgeschrittener Anwendungsfall: große Videodateien effizient verarbeiten
Bei sehr großen Videodateien erlauben Node.js-Streams, Daten direkt zwischen Quellen, dem
FFmpeg-Prozess und Zielen zu pipen, was einen hohen Speicherverbrauch vermeidet. Dieses Beispiel
zeigt, wie ein Eingabedatei-Stream an FFmpeg gepipet wird und wie der Ausgabestream von FFmpeg in
eine Datei gepipet wird. Eine Pipe erlaubt kein Springen: Verwenden Sie eine Eingabe, die sich als
Stream lesen lässt, etwa die hier gezeigte Matroska-Datei. Manche MP4/MOV-Layouts erfordern das
Springen in der Eingabe; übergeben Sie dafür den lokalen Dateinamen an
transcodeVideo, statt ihn zu pipen.
Gewöhnliches MP4 mit +faststart erfordert eine Ausgabe, in der gesprungen werden
kann. Dieses Beispiel schreibt stattdessen fragmentiertes MP4 mithilfe von
+frag_keyframe+empty_moov, wie in der
Dokumentation des FFmpeg-MOV/MP4-Muxers beschrieben. Vergewissern
Sie sich, dass Ihr Zielplayer fragmentiertes MP4 unterstützt, oder verwenden Sie das obige Beispiel
mit Dateiausgabe.
const { spawn } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const { pipeline } = require('node:stream/promises')
const { withOutputFile } = require('./video-tools.cjs')
async function streamTranscode(inputPath, outputPath) {
return withOutputFile(outputPath, async (temporaryPath) => {
const abortController = new AbortController()
const child = spawn(
'ffmpeg',
[
'-nostdin',
'-v',
'error',
'-i',
'pipe:0',
'-map',
'0:v:0',
'-map',
'0:a:0?',
'-c:v',
'libx264',
'-preset',
'medium',
'-crf',
'23',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+frag_keyframe+empty_moov',
'-f',
'mp4',
'pipe:1',
],
{ stdio: ['pipe', 'pipe', 'pipe'] },
)
let diagnostics = ''
child.stderr.on('data', (chunk) => {
diagnostics = (diagnostics + chunk.toString()).slice(-8000)
})
const exited = new Promise((resolve, reject) => {
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`FFmpeg failed (${code}): ${diagnostics}`))
})
})
const options = { signal: abortController.signal }
const inputDone = pipeline(fs.createReadStream(inputPath), child.stdin, options)
const outputDone = pipeline(
child.stdout,
fs.createWriteStream(temporaryPath, { flags: 'wx' }),
options,
)
const stop = () => {
abortController.abort()
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
try {
// Success requires both pipelines to finish as well as a zero process exit.
await Promise.all([exited, inputDone, outputDone])
} catch (error) {
stop()
await Promise.allSettled([exited, inputDone, outputDone])
throw error
} finally {
process.off('SIGINT', stop)
process.off('SIGTERM', stop)
}
})
}
streamTranscode('large_input.mkv', path.join('processed', 'large_output.mp4'))
.then((output) => console.log(`Streaming transcoding completed: ${output}`))
.catch((error) => {
console.error(error.message)
process.exitCode = 1
})
Dieser Streaming-Ansatz senkt den Speicherverbrauch deutlich. Er nutzt
pipe:0 und pipe:1, konfiguriert
stdio und enthält eine umfassende Fehlerbehandlung für den Eingabestream,
den Ausgabestream und den FFmpeg-Prozess selbst. So werden Ressourcen aufgeräumt und der Prozess
wird korrekt beendet, selbst wenn mitten im Stream Fehler auftreten. Ein Abbruch bei SIGINT oder
SIGTERM beendet den Kindprozess und entfernt dessen unvollständige Ausgabe.
Fazit
Wenn Sie die asynchrone Natur und die Streaming-Fähigkeiten von Node.js mit den leistungsstarken
Funktionen von FFmpeg zur Multimedia-Verarbeitung kombinieren, können Sie effiziente und skalierbare
Workflows zur Videobearbeitung aufbauen. Ob Sie einfaches Transkodieren, das Erzeugen von
Thumbnails, Wasserzeichen oder komplexe Stream-Verarbeitung benötigen: Diese Integration bietet eine
flexible Grundlage. Denken Sie daran, Fehler sauber zu behandeln, Kindprozesse korrekt zu verwalten,
Eingaben zu validieren und temporäre Dateien aufzuräumen. Für performancekritische Aufgaben sollten
Sie die Optionen von FFmpeg zur Hardwarebeschleunigung prüfen (z. B.
-hwaccel auto oder spezifische Optionen wie h264_videotoolbox unter
macOS).
Wenn Sie einen verwalteten Dienst für komplexe Pipelines zur Medienverarbeitung benötigen, ohne sich um die FFmpeg-Infrastruktur zu kümmern, werfen Sie einen Blick auf Transloadit.
