Stream FFmpeg output with Node.js
Use Node.js to start FFmpeg, stream its encoded bytes into a temporary file, and publish the result only when both encoding and writing have finished. This walkthrough gives you a local command that converts a video to H.264 with optional AAC audio, refuses to overwrite existing output, and cleans up partial work on failure or cancellation.
Choose what to stream
The input is a complete local file. FFmpeg opens it directly so it can seek, which matters for
MP4/MOV files whose metadata is at the end. Its output travels through stdout into a Node.js
writable stream. You retain the original input and one completed output file; no upload server is
involved.
A pipe cannot seek. For MP4 output, use +frag_keyframe+empty_moov to write an initial header and
subsequent fragments. Ordinary MP4 with +faststart needs a seekable output file instead.
FFmpeg’s MP4 muxer documentation explains
fragmentation and its compatibility tradeoff: some players and editors do not accept fragmented
MP4. Check your intended consumer before choosing this output format.
Streaming describes how bytes move here. Encoding runs as fast as the machine permits, and the final filename appears only after completion. This is a batch conversion, with no promise of real-time encoding speed, live playback, or adaptive streaming.
Prepare a local example
Use Linux, Node.js 24 or 26, and an FFmpeg build with the libx264 and aac encoders, plus ffprobe.
The example uses Node’s built-in TypeScript support
and needs no npm packages. Install missing tools using the Node.js downloads
and FFmpeg downloads pages. The commands below use Bash and a local
filesystem that supports hard links. Start with a video you trust; this command is not an upload
sandbox.
Check your tools:
node --version &&
ffmpeg -version &&
ffprobe -version &&
ffmpeg -hide_banner -encoders
Confirm that the encoder list includes libx264 and aac. The tested combinations are Node.js
26.8.1 with FFmpeg 9.0.1 on Linux, and Node.js 24.13.0 and 26.5.0 with FFmpeg 6.1.1 on Ubuntu 24.04.
Create a new directory and a three-second sample with video and a tone. Keep the && chain: if the
directory already exists or navigation fails, nothing inside it is written. The -n flag also
refuses to replace the sample.
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
Stay in that directory for the remaining commands. The sample deliberately uses a normal MP4 file;
passing its filename to FFmpeg avoids the seek restrictions of feeding it through pipe:0.
Connect FFmpeg to a writable stream
Save the complete script below as transcode.ts. The output directory is created if needed. An
existing destination is preserved, even if another process creates it while encoding is underway.
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() receives an argument array and does not run a shell. pipeline() applies backpressure:
when the writable stream is busy, Node stops draining FFmpeg’s output, and the pipe eventually makes
FFmpeg wait. It also propagates stream errors. See Node’s
stream pipeline documentation.
The first video stream is required. The ? in 0:a:0? makes the first audio stream optional, as
specified by FFmpeg’s stream mapping rules.
Padding adds a row or column when needed so H.264’s yuv420p output has even dimensions. Subtitles,
extra audio tracks, and other video streams are omitted.
Success requires both the child’s
close event with exit code zero and the
writable pipeline finishing. A failed write stops FFmpeg; a failed FFmpeg process aborts the write.
The code waits for both to settle before removing temporary work. An exclusive hard link then
publishes the completed file without a second full copy. This requires hard-link support and does
not provide power-loss durability.
Run and inspect the conversion
Run the sample, then inspect its actual 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
Expect a completion message containing the absolute output path. The probe should report H.264
video at 320 × 180, AAC audio, and a duration around three seconds. A video without audio produces
only the video stream. For your own input, use quoted paths and a new output name, for example
node transcode.ts -- "my clip.mov" "my clip encoded.mp4".
Run the same command again to check the overwrite policy: it exits with code 1 and reports EEXIST.
The original output stays unchanged. The refusal happens at publication, so the second run still
spends time encoding. Pick a different filename to keep another result.
For a conventional MP4 that an editor can open, remux the completed output to another new file:
ffmpeg -nostdin -n -v error -i output.mp4 -map 0 -c copy -movflags +faststart compatible.mp4
This copies the encoded streams without re-encoding. It writes directly to compatible.mp4, so an
interrupted remux can leave a partial file there. -n refuses an existing destination; inspect or
remove that partial file yourself before retrying. Some FFmpeg versions return status zero for an
overwrite refusal, so check the diagnostic and the file rather than relying on the status alone.
This extra file also needs disk space.
Handle failures and resource limits
Ctrl+C, SIGTERM, or the default 120-second deadline aborts processing and removes the temporary
output. Increase the deadline for a larger trusted input with --timeout 600. Once publication has
started, a completed destination may remain; cancellation never deletes it. SIGKILL, a machine
crash, or a cleanup permission failure can leave a .ffmpeg-* directory for manual inspection.
The default error line contains only a vetted code. Add --verbose for local debugging; FFmpeg
errors include at most the last 8,000 diagnostic characters. Do not forward that verbose output to
an HTTP client.
| Result | What to check |
|---|---|
ENOENT | The FFmpeg executable could not be found. Use --ffmpeg /absolute/path/to/ffmpeg if needed. |
PROCESSING_FAILED | Use --verbose to inspect an invalid or missing input, unavailable encoder, or unsupported media. |
EEXIST | Choose a new output filename. |
EACCES or ENOSPC | Check directory permissions and available disk space. |
ABORT_ERR | The job was cancelled or exceeded its deadline. |
-xerror makes FFmpeg stop on reported errors,
but a successful conversion is not proof that the source is complete or safe. Some truncated
containers still contain decodable frames. If your
application knows an expected duration or checksum, validate that separately.
This command runs one conversion, limits requested codec threads, bounds retained diagnostics, and avoids buffering the whole video in Node. FFmpeg still needs its own decoder and encoder memory; backpressure is not a memory or CPU quota. Output size is not capped either. For an upload API, stage the input first, enforce upload and disk limits, and run jobs through a bounded queue with OS resource limits. The local stream pipeline is the processing step, not a complete public service.
