CLI tool
damianociarla/node-ffmpeg avatar
damianociarla/node-ffmpeg

node-ffmpeg is a typed wrapper around the ffmpeg command line

ffmpeg module for nodejs

634 stars142 forksTypeScriptMIT

At a glance

What is it?
node-ffmpeg is a small TypeScript package that drives the ffmpeg and ffprobe executables from Node, keeping the original ffmpeg package chainable setter API while adding promises, a named factory and a client that caches capabilities. It deliberately does not ship binaries, so the application chooses its own FFmpeg build, and it needs Node 24 or newer.
Who is it for?
node-ffmpeg suits a Node service that needs to transcode, extract audio or pull frames and wants typed arguments instead of assembling an ffmpeg command line by hand, since the setters cover the common cases and the custom option methods cover the rest.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 63 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A wrapper, not a reimplementation

node-ffmpeg is a small, typed Node.js wrapper around the FFmpeg and ffprobe command-line tools, and the README is clear that it keeps the original ffmpeg package API while using modern Node.js primitives.

So the shape of the API is familiar: Promise or callback construction, chainable setters, conversion, frame extraction, audio extraction and watermarks. What changes is the implementation, the typing and the runtime requirements.

The requirements are short and they are the first thing to check. Node.js 24 or newer, and ffmpeg and ffprobe on PATH or explicit executable paths. The package name on npm is ffmpeg, and the description is a modern, typed Node.js API for processing media with FFmpeg.

The packaging choice is worth pausing on. The package intentionally does not download binaries. The README gives the reason: this keeps installation small and lets the application choose the FFmpeg build and codecs it needs.

That is a deliberate trade against the convenience of a self-contained install. A user who expects npm install to leave them with a working ffmpeg is going to be disappointed, and a user who is packaging a service with a specific codec set is going to be glad. The Node 24 floor is the other cost of taking the modern primitives seriously, and the package.json engines field says node >=24, with the.nvmrc at the repository root pinning the version for development.

Installing it and running a conversion

The install is a single command, and the README is explicit that it is not enough on its own:

sh
npm install ffmpeg

The quick start then shows the promise-based construction and the chainable setters:

ts
import ffmpeg from 'ffmpeg';

const video = await ffmpeg('/media/input.mp4');

video.on('progress', ({ percent, time }) => {
  console.log(percent === undefined ? `${time}s` : `${percent.toFixed(1)}% (${time}s)`);
});

await video
  .setVideoCodec('libx264')
  .setVideoBitRate(2_000)
  .setAudioCodec('aac')
  .setAudioBitRate(192)
  .setVideoSize('1280x?', true, true, 'black')
  .save('/media/output.mp4');

Two details in that snippet are worth pausing on. The progress handler branches on percent being undefined and falls back to printing the elapsed time, because ffmpeg does not always report a percentage and a handler that assumed it would would print NaN for some inputs. And setVideoSize takes the dimensions and then three more arguments, the boolean flags for preserving the aspect ratio and padding, and the fill colour for the padding.

The setter list is the API surface in one place. There are video setters for disabling video, format, codec, bitrate, frame rate, start time, duration, aspect ratio, size and quality, audio setters for disabling audio, codec, frequency, channels, bitrate and quality, and general setters for metadata, threads, watermark, addInput, addCommand, addOutputOption, addFilterComplex and getCommand. The execution methods are save, fnExtractSoundToMP3, fnExtractFrameToJPG and fnAddWatermark.

The package is ESM-first with a CommonJS entry point at index.cjs, and the exports map gives both, so the same install works from a require and an import.

Three ways to construct, and why the client exists

The README shows three construction styles, and the third one is the interesting one.

The first is the default import, which is what the quick start uses. The second is CommonJS, which remains callable as in 0.0.4:

js
const ffmpeg = require('ffmpeg');
const video = await new ffmpeg('/media/input.mp4');
await video.setVideoCodec('copy').setAudioCodec('copy').save('/media/copy.mp4');

The legacy callback API also remains available, with the nested error handling that shape implies.

For new TypeScript code, the README says the named factory is the clearest form:

ts
import { create } from 'ffmpeg';

const video = await create('/media/input.mp4', {
  timeout: 120_000,
  overwrite: true,
});

And then there is the client, aimed at services that process several files with one fixed FFmpeg installation:

ts
import { createClient } from 'ffmpeg';

const client = await createClient({ overwrite: true, timeout: 120_000 });
const video = await client.open('/media/input.mp4');

The client exists because of the caching. Client executable paths, environment, and working directory are snapshotted at creation, and per-open settings may adjust timeout, buffering, overwrite behavior, encoding, and cancellation without invalidating the cached capabilities. So a service that opens a thousand files pays the capability probe once rather than a thousand times, and the per-file settings still vary freely.

That is a real distinction, and it is the one piece of genuinely new API here rather than a compatibility shim. For a short script, the default import is fine. For a service, the client is the reason to use this package rather than spawning ffmpeg directly.

Settings, buffers and the error that catches truncation

The settings table is short and each entry has a consequence.

| Setting | Default | Meaning | |---|---|---| | encoding | utf8 | Process output encoding | | timeout | 0 | Maximum operation time in milliseconds; 0 disables it | | maxBuffer | 16 MiB | Conversion output tail and maximum complete probe output | | overwrite | false | Use -y; otherwise -n prevents interactive hangs | | ffmpegPath | ffmpeg | FFmpeg executable | | ffprobePath | ffprobe | ffprobe executable | | signal | none | AbortSignal used to cancel an operation | | cwd, env | inherited | Child-process working directory and environment |

The overwrite default deserves explanation because it is a footgun avoided. With overwrite false the package passes -n, so ffmpeg refuses to overwrite an existing output instead of prompting, and an interactive prompt in a child process is a hang rather than an error. Setting it to true passes -y.

The maxBuffer default of 16 MiB is the other one to understand. Long-running conversions retain only the most recent maxBuffer bytes of stdout and stderr, which is what keeps memory bounded on a big job. But probe and configuration output must remain complete, so if stdout exceeds the limit the operation fails with error 119 instead of parsing a truncated document. That is the right failure mode: a truncated ffprobe JSON would otherwise be parsed as something plausible and wrong.

The signal setting is the cancellation path, and an AbortSignal is the modern way to do it in Node.

Events, metadata and frame extraction

The event surface is where the wrapper earns its keep, because ffmpeg's own output is a stream of progress lines that every caller otherwise has to parse.

ts
video.on('start', (command) => console.log(command));
video.on('progress', (progress, operation) => {
  console.log(operation.operationId, operation.destination, progress.percent);
});
video.on('stderr', (chunk) => process.stderr.write(chunk));
video.on('end', (result) => console.log(result.code));
video.on('error', (error) => console.error(error)); // optional

The error listener is optional, and the README explains why that matters: failed Promise operations reject normally instead of causing an unhandled EventEmitter error. On a plain EventEmitter, an error event with no listener throws, which in a promise-based API means the rejection is masked by a crash. Registering an error listener is therefore not needed for correctness, which is a small but genuine ergonomic win.

The progress handler takes a second argument, the operation context, and that is how concurrency is handled. Terminal methods consume the current fluent options at invocation, their validation and process errors always arrive through Promise rejection or the optional callback, and concurrent operations on one Video use isolated option snapshots. Their shared lifecycle events may interleave, but every event includes an immutable operation context for correlation. The operation id and the destination in that context are what you use to tell two concurrent saves apart.

Metadata follows the same idea. video.metadata preserves the original package shape with duration, video, audio and common tags, and adds the complete ffprobe response at video.metadata.raw. Available formats and encoders are exposed at video.info_configuration.

Frame extraction returns paths that are limited to files matching the extraction pattern, rather than every file in the directory, and only one of every_n_frames, every_n_seconds or every_n_percentage may be set.

Executable paths, and what inputs are not supported

The path configuration is where deployments differ, and the package supports both a global and a per-operation form.

Globally, for legacy applications:

ts
ffmpeg.bin = '/opt/ffmpeg/bin/ffmpeg';
ffmpeg.ffprobeBin = '/opt/ffmpeg/bin/ffprobe';

And per operation, which is the form to prefer in new code:

ts
await ffmpeg('/media/input.mp4', {
  ffmpegPath: '/opt/ffmpeg/bin/ffmpeg',
  ffprobePath: '/opt/ffmpeg/bin/ffprobe',
});

There is a convenience in the resolution of those two. When an absolute ffmpegPath is supplied and ffprobePath is omitted, a sibling ffprobe executable is inferred. So pointing at a pinned build in one variable is enough, which matters in a container where the build lives in a versioned directory.

The input limits are stated clearly, and they are a consequence of how the package works. Hierarchical URLs such as HTTP, HTTPS, and RTSP are accepted as inputs when supported by the installed FFmpeg build. Non-hierarchical virtual inputs, stdin (-), and Node.js streams are not supported as primary inputs because initialization probes the source first.

That last clause is the trade. The package probes the source before doing anything else, which is what lets it populate the metadata and validate the input, and it is exactly why a pipe cannot be a primary input. A workflow that streams bytes into ffmpeg has to look elsewhere, or has to write to a file first.

The custom option methods cover the rest of the ffmpeg surface. addCommand takes repeated arguments, so multiple -map or -metadata entries are supported, and addOutputOption and addFilterComplex cover the other cases. The README also notes that copy is accepted by both codec setters, which is how a remux without re-encoding is expressed.

Where node-ffmpeg fits, and where it stops

The comparison worth making is with the other two ways to drive ffmpeg from Node: shelling out yourself, and using a native addon.

Against shelling out, the difference is the argument construction and the output parsing. Writing the command line by hand is fine for a one-off conversion, but the argument order in ffmpeg is positional in places, the escaping rules are unforgiving, and the progress output is a stream of human-readable lines that every project ends up parsing slightly differently. The wrapper gives you a typed surface for the common cases and a typed escape hatch for the rest.

Against a native addon, the difference is the deployment. An addon has to be compiled or fetched for each platform and Node ABI, which is a packaging problem in a container. This wrapper spawns a process, so it has no native dependency, and the cost is the process boundary: you pass files and get files, and there is no streaming of pixels or samples through JavaScript.

The limits follow from the same design. The package does not bundle binaries, so an installation is not self-sufficient and the application owns the codec set. The Node 24 floor excludes a lot of production estates. The probe-first initialization rules out streaming inputs. The default timeout of 0 means a hung ffmpeg is a hung request, so any service using this needs to set a timeout explicitly. And the maxBuffer policy means the tail of a verbose conversion is kept while the head is dropped, so if you need the whole log for a support case you have to capture stderr yourself with the event.

On maintenance, the release history is short and recent, with v1.1.3, v1.1.2 and v1.1.1 all on 2026-08-21, and the last push on the same day. The repository carries a CHANGELOG.md, a SECURITY.md, a RELEASING.md, an AUDIT.md and a CONTEXT.md, and the package scripts show a fairly complete pipeline: Vitest for unit tests with coverage, a separate integration runner, Playwright for visual tests, a VitePress documentation build, tsup for the bundle, and a check script that runs formatting, linting, tests, the build, the docs build, the visual tests and a package smoke test in sequence.

Editorial conclusion

node-ffmpeg suits a Node service that needs to transcode, extract audio or pull frames and wants typed arguments instead of assembling an ffmpeg command line by hand, since the setters cover the common cases and the custom option methods cover the rest. It is the wrong tool if you need the binaries bundled, because the package intentionally does not download them and the application picks the FFmpeg build and codecs, and it is the wrong tool if you need streaming input, since non-hierarchical virtual inputs, stdin and Node.js streams are not supported as primary inputs. Before adopting it, confirm ffmpeg and ffprobe are on PATH or set explicit paths, and set the timeout option for anything longer than a quick transcode, because the default of 0 disables the limit.

Frequently asked questions

How do I install node-ffmpeg and what does it need?

Run npm install ffmpeg. It needs Node.js 24 or newer and ffmpeg and ffprobe on PATH, or explicit executable paths. The package intentionally does not download binaries, so the application chooses the FFmpeg build and codecs it needs.

What is the difference between the default import, create and createClient in node-ffmpeg?

The default import is the promise or callback construction from the original ffmpeg package, create is the named factory recommended for new TypeScript code, and createClient is for services that process many files with one fixed FFmpeg installation, because it caches capabilities and snapshots executable paths, environment and working directory at creation.

Can node-ffmpeg read from a stream or from stdin?

No. Hierarchical URLs such as HTTP, HTTPS and RTSP are accepted as inputs when the installed FFmpeg build supports them, but non-hierarchical virtual inputs, stdin and Node.js streams are not supported as primary inputs, because initialization probes the source first.

What happens if ffmpeg produces more output than the maxBuffer allows?

Long-running conversions retain only the most recent maxBuffer bytes of stdout and stderr, which defaults to 16 MiB, but probe and configuration output must remain complete, so if stdout exceeds the limit the operation fails with error 119 instead of parsing a truncated document.

How do I pass custom ffmpeg options in node-ffmpeg?

Use addCommand for repeated arguments such as multiple -map or -metadata entries, with addOutputOption and addFilterComplex for the remaining cases, and getCommand to inspect the assembled command line. All setters are chainable, and copy is accepted by both codec setters.

Official sources

  1. damianociarla/node-ffmpeg on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/damianociarla-node-ffmpeg.svg)](https://hysenlabs.com/projects/damianociarla-node-ffmpeg)

Was this useful?