Transmettre la sortie de FFmpeg avec Node.js
Utilisez Node.js pour lancer FFmpeg, transmettre ses octets encodés dans un fichier temporaire et publier le résultat uniquement lorsque l’encodage et l’écriture sont tous deux terminés. Ce guide vous fournit une commande locale qui convertit une vidéo en H.264 avec un flux audio AAC facultatif, refuse d’écraser une sortie existante et supprime les fichiers partiels en cas d’échec ou d’annulation.
Choisir les données à transmettre en flux
L’entrée est un fichier local complet. FFmpeg l’ouvre directement pour pouvoir s’y déplacer, ce qui
compte pour les fichiers MP4/MOV dont les métadonnées se trouvent à la fin. Sa sortie passe par
stdout pour alimenter un flux d’écriture Node.js. Vous conservez le fichier
d’entrée original et un fichier de sortie terminé ; aucun serveur de téléversement n’intervient.
Un tube ne permet pas de se déplacer dans les données. Pour une sortie MP4, utilisez
+frag_keyframe+empty_moov pour écrire un en-tête initial, puis des fragments. Un MP4 classique avec
+faststart nécessite plutôt un fichier de sortie permettant de s’y déplacer.
La documentation du multiplexeur MP4 de FFmpeg explique
la fragmentation et son compromis de compatibilité : certains lecteurs et éditeurs n’acceptent pas
le MP4 fragmenté. Vérifiez la compatibilité de l’outil destinataire avant de choisir ce format de sortie.
La transmission en flux décrit ici la manière dont les octets circulent. L’encodage s’exécute aussi vite que la machine le permet, et le nom de fichier définitif n’apparaît qu’une fois le traitement terminé. Il s’agit d’une conversion par lot, sans promesse de vitesse d’encodage en temps réel, de lecture en direct ni de diffusion adaptative.
Préparer un exemple local
Utilisez Linux, Node.js 24 ou 26 et une compilation de FFmpeg comprenant les encodeurs
libx264 et aac, ainsi que ffprobe.
L’exemple utilise la prise en charge native de TypeScript dans Node.js
et ne nécessite aucun paquet npm. Installez les outils manquants à partir des pages de
téléchargement de Node.js et de
téléchargement de FFmpeg. Les commandes ci-dessous utilisent Bash et
un système de fichiers local prenant en charge les liens physiques. Commencez avec une vidéo de
confiance ; cette commande n’est pas un environnement isolé pour les téléversements.
Vérifiez vos outils :
node --version &&
ffmpeg -version &&
ffprobe -version &&
ffmpeg -hide_banner -encoders
Vérifiez que la liste des encodeurs contient libx264 et
aac. Les combinaisons testées sont Node.js 26.8.1 avec FFmpeg 9.0.1 sous
Linux, ainsi que Node.js 24.13.0 et 26.5.0 avec FFmpeg 6.1.1 sous Ubuntu 24.04.
Créez un nouveau répertoire et un échantillon de trois secondes contenant une vidéo et un son.
Conservez l’enchaînement avec && : si le répertoire existe déjà ou si le
changement de répertoire échoue, rien n’y est écrit. L’option -n refuse
également de remplacer l’échantillon.
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
Restez dans ce répertoire pour les commandes suivantes. L’échantillon utilise délibérément un
fichier MP4 classique ; transmettre son nom de fichier à FFmpeg évite les restrictions de déplacement
qu’imposerait son acheminement par pipe:0.
Relier FFmpeg à un flux d’écriture
Enregistrez le script complet ci-dessous sous transcode.ts. Le répertoire de sortie
est créé si nécessaire. Une destination existante est préservée, même si un autre processus la crée
pendant l’encodage.
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() reçoit un tableau d’arguments et n’exécute pas de shell.
pipeline() applique une contre-pression : lorsque le flux d’écriture est occupé,
Node cesse de vider la sortie de FFmpeg, et le tube finit par faire attendre FFmpeg. Cette fonction
propage également les erreurs de flux. Consultez la
documentation du pipeline de flux de Node.js.
Le premier flux vidéo est obligatoire. Le caractère ? dans
0:a:0? rend le premier flux audio facultatif, conformément aux
règles de sélection des flux de FFmpeg. Le remplissage ajoute une
ligne ou une colonne si nécessaire pour que la sortie H.264 au format yuv420p
ait des dimensions paires. Les sous-titres, les pistes audio supplémentaires et les autres flux vidéo
sont omis.
La réussite exige à la fois que le processus enfant émette
l’événement close avec un code de sortie nul
et que le pipeline d’écriture se termine. Un échec d’écriture arrête FFmpeg ; un échec du processus
FFmpeg interrompt l’écriture. Le code attend que les deux se terminent, avec ou sans erreur, avant de
supprimer les fichiers temporaires. Un lien physique créé de manière exclusive publie ensuite le
fichier terminé sans seconde copie complète. Cela nécessite la prise en charge des liens physiques
et ne garantit pas la persistance des données en cas de coupure de courant.
Exécuter et examiner la conversion
Exécutez l’exemple, puis examinez les flux réellement présents dans sa sortie :
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
Vous devriez obtenir un message de fin contenant le chemin absolu de la sortie. L’analyse devrait
indiquer une vidéo H.264 de 320 × 180, de l’audio AAC et une durée d’environ trois secondes. Une vidéo
sans audio ne produit que le flux vidéo. Pour votre propre entrée, utilisez des chemins entre
guillemets et un nouveau nom de sortie, par exemple node transcode.ts -- "my clip.mov" "my clip encoded.mp4".
Relancez la même commande pour vérifier la règle d’écrasement : elle se termine avec le code 1 et
signale EEXIST. La sortie originale reste inchangée. Le refus intervient au
moment de la publication ; la seconde exécution consacre donc tout de même du temps à l’encodage.
Choisissez un autre nom de fichier pour conserver un autre résultat.
Pour obtenir un MP4 classique qu’un éditeur peut ouvrir, remultiplexez la sortie terminée dans un autre nouveau fichier :
ffmpeg -nostdin -n -v error -i output.mp4 -map 0 -c copy -movflags +faststart compatible.mp4
Cette opération copie les flux encodés sans les réencoder. Elle écrit directement dans
compatible.mp4 ; un remultiplexage interrompu peut donc y laisser un fichier partiel.
-n refuse une destination existante ; examinez ou supprimez vous-même ce
fichier partiel avant de réessayer. Certaines versions de FFmpeg renvoient un statut nul lorsqu’elles
refusent un écrasement : vérifiez donc le diagnostic et le fichier au lieu de vous fier uniquement au
statut. Ce fichier supplémentaire nécessite également de l’espace disque.
Gérer les échecs et les limites de ressources
Ctrl+C, SIGTERM ou l’expiration du délai par défaut de 120 secondes interrompent le traitement et
suppriment la sortie temporaire. Augmentez ce délai pour une entrée de confiance plus volumineuse
avec --timeout 600. Une fois la publication commencée, un fichier de destination
terminé peut subsister ; l’annulation ne le supprime jamais. SIGKILL, un plantage de la machine ou un
échec du nettoyage dû aux permissions peuvent laisser un répertoire .ffmpeg-*
à examiner manuellement.
La ligne d’erreur par défaut contient uniquement un code vérifié. Ajoutez
--verbose pour le débogage local ; les erreurs de FFmpeg incluent au maximum
les 8 000 derniers caractères de diagnostic. Ne transmettez pas cette sortie détaillée à un client
HTTP.
| Résultat | Points à vérifier |
|---|---|
ENOENT | L’exécutable FFmpeg est introuvable. Utilisez --ffmpeg /absolute/path/to/ffmpeg si nécessaire. |
PROCESSING_FAILED | Utilisez --verbose pour examiner une entrée invalide ou manquante, un encodeur indisponible ou un média non pris en charge. |
EEXIST | Choisissez un nouveau nom de fichier de sortie. |
EACCES ou ENOSPC | Vérifiez les permissions du répertoire et l’espace disque disponible. |
ABORT_ERR | Le traitement a été annulé ou a dépassé son délai. |
-xerror fait arrêter FFmpeg en cas d’erreurs
signalées, mais une conversion réussie ne prouve pas que la source est complète ou sûre. Certains
conteneurs tronqués contiennent encore des images décodables. Si votre application connaît une
durée ou une somme de contrôle attendue, validez ce point séparément.
Cette commande exécute une conversion, limite le nombre de threads demandés pour les codecs, limite les diagnostics conservés et évite de mettre la vidéo entière en mémoire tampon dans Node. FFmpeg a toujours besoin de mémoire pour ses propres décodeurs et encodeurs ; la contre-pression n’est pas un quota de mémoire ou de processeur. La taille de la sortie n’est pas plafonnée non plus. Pour une API de téléversement, stockez d’abord l’entrée dans un espace temporaire, imposez des limites de téléversement et d’espace disque, puis exécutez les tâches via une file d’attente de capacité limitée avec des limites de ressources du système d’exploitation. Le pipeline local de flux constitue l’étape de traitement, pas un service public complet.
