npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ffm-script

v1.6.0

Published

Modern, dependency-free TypeScript wrapper around the FFmpeg CLI: probe, transcode (MP4/MOV/MKV/WebM), trim, extract audio, loudness normalization, resampling, silence trimming, thumbnails, sprite-sheet storyboards with WebVTT, HLS packaging, watermarks,

Readme

ffm-script

CI codecov npm version

A modern, TypeScript-native wrapper around the FFmpeg binary for common media operations — a spiritual successor to fluent-ffmpeg (archived in May 2025).

  • 🟦 TypeScript-first — strict types, full JSDoc, dual ESM + CJS builds.
  • 🪶 Zero runtime dependencies — it shells out to the FFmpeg binary you already have.
  • 🎯 Fluent, chainable APIprobe, convert, trim, extractAudio, thumbnail and more.
  • 🤖 AI-agent ready — ships an Agent Skill so Claude Code, Cursor & Codex know the exact API instead of hallucinating options.
  • Progress & cancellationonProgress callbacks and AbortSignal support.
  • 🧱 Typed errors — catch exactly what went wrong.

Quick start

import { ffmscript } from 'ffm-script';

// Fluent, typed, and zero-dependency
await ffmscript('input.mp4')
  .trim({ start: 60, end: 180 })
  .convert({ width: 1280, quality: 'high' })
  .save('output.mp4', { onProgress: (p) => console.log(`${p.percent}%`) });

Chained trim + convert run in a single FFmpeg pass, not one process per step. Prefer standalone functions? Every operation is also exported on its own — jump to Usage.

The best fluent-ffmpeg alternative in 2026

fluent-ffmpeg was the de-facto way to drive FFmpeg from Node, but it was archived in May 2025. The remaining options are low-level native C bindings (node-av, @mmomtchev/ffmpeg) — powerful but complex and segfault-prone — or ffmpeg.wasm, which doesn't run server-side in Node. ffm-script is the modern, TypeScript-native replacement: a small, high-level API that wraps the FFmpeg binary for the operations applications actually need.

Prerequisites

FFmpeg (which provides both ffmpeg and ffprobe) must be installed and available:

| Platform | Install | | -------- | ---------------------------------- | | macOS | brew install ffmpeg | | Ubuntu | sudo apt-get install ffmpeg | | Windows | winget install Gyan.FFmpeg | | Other | https://ffmpeg.org/download.html |

If the binaries aren't on your PATH, point the library at them with the FFMPEG_PATH and FFPROBE_PATH environment variables.

Requires Node.js >= 22.

Install

pnpm add ffm-script
# or: npm install ffm-script / yarn add ffm-script

Formats: input MP4 / MOV / WebM / MKV (plus MP3 / AAC / WAV / FLAC / M4A for audio); video output is MP4. Audio extraction targets MP3/AAC, the audio toolkit also writes WAV/FLAC; thumbnails target JPEG/PNG, storyboard sprite sheets JPEG/PNG/WebP.

Usage

Runnable examples: every snippet below has an executable counterpart in examples/ — one script per feature. Run them all against the bundled fixture with pnpm examples, or one at a time: pnpm examples trim.

Check FFmpeg is available

Fail fast at startup with a clear, actionable message:

import { checkDependencies } from 'ffm-script';

checkDependencies(); // throws FFmpegNotFoundError if ffmpeg/ffprobe are missing

Read metadata — probe

import { probe } from 'ffm-script';

const info = await probe('video.mp4');
console.log(info.duration); // 124.5 (seconds)
console.log(info.video?.codec); // "h264"
console.log(info.video?.width); // 1920
console.log(info.audio?.codec); // "aac"
console.log(info.tags.title); // container metadata (title, artist, creation_time…)
console.log(info.audio?.tags.language); // per-stream metadata (e.g. "eng")

tags is a Record<string, string> of metadata. It's present at the top level (container tags such as title, artist, album, creation_time) and on every stream (per-track tags such as language), defaulting to an empty object when the file carries none. Write these tags back with setMetadata.

Read & write metadata — setMetadata

Write or strip metadata tags. Streams are stream-copied (-c copy), so it's lossless and near-instant — editing tags never re-encodes the media:

import { setMetadata } from 'ffm-script';

// Set tags on top of the existing metadata
await setMetadata('input.mp4', 'output.mp4', {
  tags: { title: 'My Movie', artist: 'Me', comment: 'Shot on location' },
});

// Replace: drop the input's tags first, keep only the new ones
await setMetadata('input.mp4', 'output.mp4', { tags: { title: 'Clean' }, clear: true });

// Strip everything (anonymise) — clear with no tags
await setMetadata('input.mp4', 'output.mp4', { clear: true });

Keys are FFmpeg metadata keys (title, artist, album, comment, copyright, creation_time, …). Works on audio-only files (MP3/AAC/WAV/FLAC/M4A) as well as video. Use the same container for the output as the input so the stream copy stays valid. Calling it with neither tags nor clear is a no-op and throws InvalidOptionsError.

Transcode — convert

import { convert } from 'ffm-script';

await convert('input.mp4', 'output.mp4', {
  videoCodec: 'libx264', // default for MP4/MOV/MKV
  audioBitrate: '192k',
  width: 1280, // height auto-scaled to preserve aspect ratio
  onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`),
});

Output container

The output container is chosen from the output extension — no extra option. Codecs default to the container's natural pair when you don't pass videoCodec/audioCodec:

await convert('input.mp4', 'output.webm'); // → VP9 + Opus, no config needed
await convert('input.mp4', 'output.mkv'); // → h264 + AAC

| Extension | Default video | Default audio | | ------------------------ | ------------------ | ---------------- | | .mp4 / .mov / .mkv | libx264 (h264) | aac | | .webm | libvpx-vp9 (vp9) | libopus (opus) |

An explicit codec the container can't carry (e.g. videoCodec: 'libx264' with .webm) throws InvalidFormatError. MKV accepts any codec. parallelConvert writes .mp4/.mov/.mkv (not .webm — its copy-based join produces h264/aac; use convert for WebM).

Quality presets

convert, parallelConvert and the chainable .convert(...) accept a semantic quality preset instead of fiddling with bitrates:

await convert('input.mp4', 'output.mp4', { quality: 'high' });

| Preset | Means | Use it for | | ---------- | -------------------------- | ------------------------------- | | high | Near-transparent quality | Visually lossless, larger files | | balanced | Sensible default trade-off | Most transcodes | | small | Noticeably compressed | Smaller files, lower quality |

quality is constant-quality encoding, so it's mutually exclusive with an explicit video bitrate (videoBitrate, which targets a size) — setting both throws InvalidOptionsError. Pick one.

Every encoder exposes that dial under a different flag and on a different scale, so the preset is translated for the encoder you actually picked:

| Encoder family | high | balanced | small | | -------------------------- | ---------------------------- | ---------------------------- | ---------------------------- | | libx264 / libx265 | -crf 18 -preset slow | -crf 23 -preset medium | -crf 28 -preset medium | | *_nvenc (NVIDIA) | -cq 19 -preset p6 | -cq 23 -preset p4 | -cq 28 -preset p4 | | *_qsv (Intel Quick Sync) | -global_quality 19 | -global_quality 23 | -global_quality 28 | | *_videotoolbox (Apple) | -q:v 65 | -q:v 55 | -q:v 40 | | *_vaapi (Linux) | -qp 19 | -qp 23 | -qp 28 | | *_amf (AMD) | -rc cqp -qp_i 19 -qp_p 19 | … 23 | … 28 | | libvpx-vp9 | -crf 24 -b:v 0 | -crf 31 -b:v 0 | -crf 37 -b:v 0 | | libsvtav1 | -crf 28 -preset 6 | -crf 35 -preset 8 | -crf 45 -preset 8 | | libaom-av1 | -crf 25 -b:v 0 -cpu-used 4 | -crf 32 -b:v 0 -cpu-used 5 | -crf 40 -b:v 0 -cpu-used 6 |

Hardware encoders are matched by API suffix, so hevc_nvenc and av1_nvenc follow the *_nvenc row. Note that *_videotoolbox's scale is inverted (higher -q:v = better). An encoder in no known family (e.g. mpeg4) has no equivalent dial and throws InvalidOptionsError — use videoBitrate there.

Hardware acceleration

hwaccel decodes the input on the GPU. Pair it with a hardware videoCodec to move the encode there too:

import { listHwaccels, convert } from 'ffm-script';

const accels = await listHwaccels(); // e.g. ['videotoolbox']

await convert('input.mp4', 'output.mp4', {
  hwaccel: 'videotoolbox', // -hwaccel: hardware decoding
  videoCodec: 'h264_videotoolbox', // -c:v: hardware encoding
  quality: 'balanced', // → -q:v 55, this encoder's dialect
});

Common pairs: cuda + h264_nvenc (NVIDIA), qsv + h264_qsv (Intel), videotoolbox + h264_videotoolbox (macOS), vaapi + h264_vaapi (Linux).

A few things worth knowing:

  • hwaccel alone only accelerates decoding. Frames are handed back to system memory afterwards, which is exactly what keeps it composable with width/height and every other filter-based option.
  • listHwaccels() reports what the FFmpeg build supports, not what this machine can run — a build compiled with CUDA still lists cuda on a laptop with no NVIDIA GPU. Treat it as a shortlist and keep a software fallback.
  • The value is passed to FFmpeg untouched, so a method that isn't usable surfaces as an FFmpegError rather than a typed rejection. That's deliberate: validating against a fixed list would reject methods a newer FFmpeg supports.
  • Full-GPU pipelines are out of scope. Keeping frames on the device (-hwaccel_output_format cuda with scale_cuda) needs a filter chain this API doesn't build — reach for run if you want that.

parallelConvert takes both options too, applying them to every chunk — see Parallel transcode.

Cut — trim

import { trim } from 'ffm-script';

await trim('input.mp4', 'output.mp4', {
  start: '00:01:00',
  end: '00:03:00',
  mode: 'fast', // 'fast' = no re-encode, cuts on the nearest keyframe (default)
  // 'precise' = re-encode for a frame-accurate cut (slower)
});

Extract audio — extractAudio

import { extractAudio } from 'ffm-script';

await extractAudio('input.mp4', 'output.mp3', {
  codec: 'mp3', // or inferred from the .mp3 / .aac / .m4a extension
  bitrate: '320k',
});

Normalize loudness — normalizeAudio

EBU R128 loudness normalization, run as two FFmpeg passes: the first measures the input, the second corrects it with those measurements in hand. A single-pass loudnorm has to ride the level as it goes, which audibly pumps on anything with dynamics.

import { normalizeAudio } from 'ffm-script';

// Streaming/podcast target (the defaults: -16 LUFS, -1.5 dBTP, LRA 11)
await normalizeAudio('episode.wav', 'episode.mp3', { audioBitrate: '192k' });

// Broadcast target, and the video stream copied through untouched
await normalizeAudio('input.mp4', 'output.mp4', {
  targetLoudness: -23, // EBU R128
  truePeak: -2,
  loudnessRange: 7,
  onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`), // spans both passes
});

Accepts video or audio input. Writing to .mp4 / .mov / .mkv / .webm copies the video stream (-c:v copy, no generation loss); writing to .mp3 / .aac / .m4a / .wav / .flac drops it. Budget roughly twice the wall time of a one-pass encode — the input is decoded twice.

The output sample rate is always pinned (to the input's own rate unless you pass sampleRate): loudnorm resamples to 192 kHz internally, and an unpinned output would inherit that.

Resample — resampleAudio

import { resampleAudio } from 'ffm-script';

// What a speech-to-text pipeline usually wants
await resampleAudio('input.mp4', 'speech.wav', { sampleRate: 16000, channels: 1 });

At least one of sampleRate / channels is required. Same input/output formats as normalizeAudio, and the video stream travels the same way.

Trim silence — trimSilence

import { trimSilence } from 'ffm-script';

// Cut the dead air at both ends, keeping 0.2s of lead-in
await trimSilence('take.wav', 'tight.wav', { keepSilence: 0.2 });

// Also collapse the interior gaps down to one second
await trimSilence('take.wav', 'tight.wav', { mode: 'all', minDuration: 1, threshold: -45 });

| Option | Default | Meaning | | ------------- | -------- | ---------------------------------------------------------------------- | | mode | 'both' | 'start' / 'end' / 'both' trim the edges; 'all' also the middle | | threshold | -50 | dB below which audio counts as silence | | minDuration | 1 | 'all' only: interior silence is shortened to this many seconds | | keepSilence | 0 | seconds of lead-in left in place |

Audio in, audio out. A video input is rejected on purpose: cutting the audio timeline without cutting the picture to match would desynchronize them. There is no onProgress either — the output is shorter than the input by design, so any percentage derived from FFmpeg's timestamps would be wrong. Note that the end / both / all modes reverse the stream internally (areverse), which buffers it whole in memory: fine for an episode, not for a multi-hour recording.

Capture a thumbnail — thumbnail

import { thumbnail } from 'ffm-script';

await thumbnail('input.mp4', 'thumb.jpg', {
  timestamp: 30, // seconds, or '00:00:30'
  width: 640,
});

Package as HLS — toHLS

import { toHLS } from 'ffm-script';

await toHLS('input.mp4', './output/', {
  segmentDuration: 6,
  resolutions: [
    { width: 1920, bitrate: '5000k' },
    { width: 1280, bitrate: '2500k' },
    { width: 854, bitrate: '1000k' },
  ],
  onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`),
});
// → output/master.m3u8 + output/1920/ + output/1280/ + output/854/

Pass segmentType: 'fmp4' for fragmented-MP4 / CMAF segments (.m4s + a per-variant init MP4) instead of the default MPEG-TS .ts — for modern and low-latency HLS players.

Package audio as HLS — audioToHLS

The audio-only counterpart of toHLS: an AAC bitrate ladder (no scaling) for streaming sound — radio, podcasts. Accepts MP3/AAC/WAV/FLAC/M4A input.

import { audioToHLS } from 'ffm-script';

await audioToHLS('podcast.wav', './output/', {
  bitrates: ['128k', '64k'], // default: ['128k']
  segmentType: 'fmp4', // 'ts' (default) | 'fmp4'
  segmentDuration: 6,
});
// → output/master.m3u8 + output/128k/ + output/64k/

A master.m3u8 is written even for a single bitrate, so players always target the same URL. Each variant folder is named after its bitrate.

Scrubbing storyboard — toSprites

The preview that appears when a viewer hovers the progress bar. Samples thumbnails at a fixed interval, packs them into sprite sheets, and writes the WebVTT that maps each moment of the timeline to its tile — the natural companion to toHLS.

import { toSprites } from 'ffm-script';

await toSprites('input.mp4', './output/', {
  interval: 10, // seconds between thumbnails (default 10)
  width: 160, // thumbnail width; height keeps the aspect ratio (default 160)
  columns: 5, // thumbnails per row (default 5)
  rows: 5, // rows per sheet (default 5)
  format: 'jpg', // 'jpg' (default) | 'png' | 'webp'
});
// → output/sprite_000.jpg, output/sprite_001.jpg, … + output/storyboard.vtt

| Option | Type | Default | Notes | | ---------- | -------------------------- | ------- | ---------------------------------------------------------------------- | | interval | number | 10 | Seconds between thumbnails. Lower it for short clips. | | width | number | 160 | Thumbnail width in px; height is derived from the source. | | columns | number | 5 | Thumbnails per sheet row. | | rows | number | 5 | Rows per sheet — a new sheet starts every columns * rows thumbnails. | | format | 'jpg' \| 'png' \| 'webp' | 'jpg' | Sheet image format, and the extension used in the VTT URLs. |

The generated storyboard.vtt points at the sheets by relative URL with an #xywh= media fragment, so serving the output directory as-is is enough:

WEBVTT

00:00:00.000 --> 00:00:10.000
sprite_000.jpg#xywh=0,0,160,90

00:00:10.000 --> 00:00:20.000
sprite_000.jpg#xywh=160,0,160,90

Point your player's thumbnail track at that file — video.js, hls.js, Plyr and JW all read this format.

Two details worth knowing: the thumbnail height is computed from the source (rotation included) rather than left to FFmpeg, so the #xywh= fragments are pixel-exact; and the file names are fixed, like the HLS playlists — give it a dedicated directory. A grid whose sheet would exceed 16384px on either edge is rejected with InvalidOptionsError, since browsers and mobile decoders refuse images that large.

Chainable API — ffmscript

Fuse trim and convert into a single FFmpeg pass (not separate processes):

import { ffmscript } from 'ffm-script';

await ffmscript('input.mp4')
  .trim({ start: 60, end: 180 })
  .convert({ width: 1280 })
  .save('output.mp4', { onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`) });

Composable filters — one pass, one re-encode

.burnSubtitles() and .overlay() join the same pass, so resizing, burning in subtitles and stamping a watermark cost one re-encode instead of three (three decodes, three encodes, a lost generation each round):

await ffmscript('input.mp4')
  .trim({ start: 2, end: 30 })
  .convert({ width: 1280, quality: 'balanced' })
  .burnSubtitles({ subtitles: 'subs.srt' }) // or { track: 0 } for an embedded one
  .overlay({ watermark: 'logo.png', position: 'top-right', opacity: 0.6, width: 120 })
  .save('output.mp4');

They take the same options as burnSubtitles and overlay — minus onProgress/signal, which belong to .save(). The last call of each wins.

The filters always run in a fixed order, whatever the call order: scale → subtitles → overlay. Scaling first renders the subtitles at the output resolution (crisp text, instead of being burnt in and then resampled), and the watermark comes last so it sits on top of them.

Two things to know:

  • With .trim(), subtitle cues land on the trimmed timeline: the input seek restarts timestamps, so a cue at 00:00:01 shows one second after the cut, not one second into the original file.
  • A watermark needs a second input, so that pass is built as a -filter_complex with explicit stream mapping. .raw() flags that would override it (-vf, -filter:v, -filter_complex, -map) throw an InvalidOptionsError rather than silently producing a wrong output — build the whole graph yourself with .raw() or run in that case.

Need a flag the typed options don't expose? .raw(args) injects arbitrary FFmpeg arguments into the same fused pass — the in-pipeline counterpart to run:

await ffmscript('input.mp4')
  .trim({ start: 60, end: 180 })
  .raw(['-vf', 'eq=contrast=1.2', '-crf', '18'])
  .save('output.mp4');

Raw flags are appended to the output side, after the options generated from trim/convert, so an explicit flag wins over a generated one (a -vf here overrides the scale from .convert({ width })). .raw() forces a re-encode — for pure stream-copy or muxer-only tweaks, use run instead.

Parallel transcode — parallelConvert

Splits the video on keyframe boundaries, re-encodes the chunks across N workers, then joins them without re-encoding (artefact-free). The audio is encoded in a single continuous pass and muxed back, so the joins stay drift-free no matter how many chunks the video is cut into. Accepts MP4, MOV, WebM and MKV inputs (output is always MP4) — keyframes come from the ISOBMFF stss box when available, otherwise from ffprobe:

import { parallelConvert } from 'ffm-script';

await parallelConvert('input.mp4', 'output.mp4', {
  workers: 4,
  videoBitrate: '2000k',
  width: 1280, // height auto-scaled to preserve aspect ratio
  onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`),
});

Don't expect a speedup on a single machine. FFmpeg (libx264) already saturates every core with its internal threading — splitting the video and running N local workers only re-shares the same cores. At equal settings, a local parallelConvert finishes in about the same time as convert (sometimes slightly slower, from the split/concat overhead). This is expected, not a bug.

Performance model: one machine vs many

The chunked model — the one YouTube and Netflix run — pays off when the chunks are encoded on independent hardware: compute power then adds up instead of being re-shared, and throughput scales near-linearly with the number of machines. ffm-script implements the building block (keyframe-accurate splitting, segment planning, chunks that re-join without re-encoding); the distribution itself — queue, remote workers, moving chunks around — is your orchestration layer.

What parallelConvert guarantees locally is the correctness of that chunked pipeline: the output keeps the source duration and both tracks, with artefact-free joins and drift-free audio. That's the validation of the building block, not a speed promise.

workers is optional. It defaults to half the host's logical cores (at least 1) so the machine stays usable during the transcode — each FFmpeg worker is itself multithreaded, so one worker per core would oversubscribe the CPU. That same internal threading is why adding local workers doesn't speed anything up. A value above the core count is capped to it.

Distributing chunks across machines — executor

To actually get that scaling, hand parallelConvert an executor: a function that encodes one segment and returns its chunk path. parallelConvert keeps doing everything else — planning the keyframe split, encoding the audio in one continuous pass, joining the chunks — and just calls your executor to produce each video chunk, so you decide where each encode runs:

import { parallelConvert, type SegmentExecutor } from 'ffm-script';

const executor: SegmentExecutor = async (segment, ctx) => {
  // `segment` = { index, startTime, endTime? }; the last segment has no endTime (runs to EOF).
  // `ctx.encodeArgs` = the shared video-encode flags every chunk must use, e.g.
  //   ['-an', '-c:v', 'libx264', '-b:v', '2000k', '-vf', 'scale=1280:-2'].
  // `ctx.inputArgs` = flags that must come BEFORE the -i (the hardware decoder); [] when none.
  // Dispatch it to a remote worker (HTTP, queue, …) that runs:
  //   ffmpeg <ctx.inputArgs> -ss <segment.startTime> -i <ctx.input> [-t <ctx.duration>] <ctx.encodeArgs> -y chunk.mp4
  // then return the path to the retrieved chunk (readable on this machine for the join).
  return sendToRemoteWorker(segment, ctx);
};

await parallelConvert('input.mp4', 'output.mp4', {
  executor,
  concurrency: 16, // segments in flight — NOT capped to local cores when an executor is set
  videoBitrate: '2000k',
  retries: 2, // re-attempt a segment whose (remote) encode fails, before giving up
  retryDelay: 1000, // optional wait (ms) between attempts
});

Without an executor, parallelConvert uses a built-in local executor (a plain FFmpeg process per chunk) — so a bare call is unchanged. Two rules the executor must respect: every chunk uses ctx.encodeArgs unchanged (identical encoding on all chunks is what lets the joins stream-copy), and the returned path must be readable by the machine running parallelConvert when it joins them. The audio is always encoded locally in one continuous pass and muxed back — never split across chunks, which would accumulate gaps and A/V drift.

Failure recovery. Across a fleet, a worker dying mid-encode is normal. Set retries and a failed segment is re-attempted (calling your executor again, so a retrying executor can route it to a healthy worker); retryDelay spaces the attempts. An aborted run is never retried — cancellation is intentional. Without retries, the first failure rejects the whole call (the default, unchanged).

Prefer the low-level pieces? resolveKeyframes and planSegments are also exported, so you can plan the split and run the joins yourself with concat — the executor seam is just the ergonomic path that reuses parallelConvert's audio handling and joining.

width / height resize the output just like convert — set one to preserve the aspect ratio, or both to force exact dimensions. The same scale is applied to every chunk, so the joins stay artefact-free.

videoCodec picks the chunk encoder (default libx264), and hwaccel decodes each chunk on the GPU — the same options as convert, applied identically to every chunk, which is what keeps the joins stream-copyable:

await parallelConvert('input.mp4', 'output.mp4', {
  hwaccel: 'cuda',
  videoCodec: 'h264_nvenc',
  quality: 'balanced', // → -cq 23 -preset p4, this encoder's dialect
});

The encoder must be muxable in the output container (checked up front, InvalidFormatError otherwise) since the chunks are joined and muxed with a stream copy. With a custom executor, hwaccel is handed over as ctx.inputArgs rather than used locally.

The output container follows the extension — .mp4, .mov or .mkv. WebM is rejected: the chunks and the aac audio are stream-copied at the joins, which WebM can't carry — use convert for WebM.

Batch many files — processBatch

parallelConvert parallelises the chunks of one file. To run an operation across many files with a bounded number in flight at once, use processBatch:

import { processBatch, convert } from 'ffm-script';

const files = ['a.mov', 'b.mov', 'c.mov'];

await processBatch(files, (file, i) => convert(file, `out/${i}.mp4`, { quality: 'balanced' }), {
  concurrency: 4, // at most 4 encodes running at a time
  onProgress: (done, total) => console.log(`${done}/${total} done`),
});

The task is any async function, so processBatch composes with every operation (or your own work). It resolves with each task's result in input order, whatever order they finish in:

const infos = await processBatch(files, (file) => probe(file));
// infos[0] is probe(files[0]), infos[1] is probe(files[1]), …
  • concurrency defaults to half the host's logical cores (at least 1) — the right default when each task is itself an FFmpeg process that already saturates the CPU. It isn't capped, so raise it for I/O-bound tasks.
  • onProgress(done, total) is a plain file counter (not the Progress object the FFmpeg operations report), fired after each task completes.
  • Fail-fast: the first task to reject rejects the whole batch (like Promise.all). Tasks already running aren't cancelled by the library — pass your own signal into the task if you need to stop them mid-flight. A signal on processBatch itself stops the pool from launching further tasks.

Concatenate files — concat

Join several videos into one MP4. FFmpeg has two concat mechanisms and the classic trap is picking the wrong one, so concat exposes both behind a familiar fast / precise choice — plus auto, which probes the inputs and decides for you:

import { concat } from 'ffm-script';

await concat(['intro.mp4', 'main.mp4', 'outro.mp4'], 'out.mp4', {
  mode: 'auto', // 'fast' | 'precise' | 'auto' (default)
  onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`),
});

| Mode | Mechanism | Re-encode? | Constraint | | --------- | ---------------------------------------- | ----------- | --------------------------------------------------------------------------------- | | fast | concat demuxer (-c copy) | No, fast | Inputs must share the same codecs/resolution/parameters, or the output is corrupt | | precise | concat filter (-filter_complex concat) | Yes, slower | Handles heterogeneous inputs | | auto | probes the inputs | When needed | Picks fast for compatible inputs, precise otherwise |

precise needs every input to agree on whether it carries an audio track (all or none); mixing the two throws InvalidOptionsError.

Watermark — overlay

Burn an image (PNG/JPEG/WebP) onto a video. Anchor it to a corner or the centre, inset it from the edges, fade it, and scale it. The video is re-encoded; the audio is copied through untouched:

import { overlay } from 'ffm-script';

await overlay('input.mp4', 'output.mp4', {
  watermark: 'logo.png',
  position: 'bottom-right', // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'
  margin: 20, // px from the edges (ignored for 'center'); default 10
  opacity: 0.6, // 0–1; default 1 (opaque)
  width: 160, // scale the watermark; height preserves aspect ratio
  onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`),
});

Only watermark is required — it defaults to a fully opaque logo in the bottom-right corner at its native size.

Subtitles — extractSubtitles / burnSubtitles

Pull a subtitle track out into a standalone file (.srt, .vtt or .ass) — the embedded codec is converted to the format you ask for via the output extension:

import { extractSubtitles } from 'ffm-script';

await extractSubtitles('movie.mkv', 'subs.srt', { track: 0 }); // track defaults to 0

Or hardcode subtitles into the picture (burn-in) — from an external file, or from a track already embedded in the input. The video is re-encoded; the audio is copied through:

import { burnSubtitles } from 'ffm-script';

// From an external file
await burnSubtitles('input.mp4', 'output.mp4', { subtitles: 'subs.srt' });

// From an embedded track
await burnSubtitles('movie.mkv', 'output.mp4', { track: 0 });

Animated GIF / WebP — toAnimation

Export a slice of a video as an animated image. The format is taken from the output extension — .gif (with a per-clip generated palette for crisp colours) or .webp (truecolour animated WebP):

import { toAnimation } from 'ffm-script';

await toAnimation('input.mp4', 'clip.gif', {
  start: 3, // seconds, or 'HH:MM:SS[.ms]'; default 0
  end: 6, // default end of the input
  fps: 12, // default 15
  width: 480, // scaled, aspect ratio preserved
  loop: 0, // 0 loops forever (default), -1 plays once
});

await toAnimation('input.mp4', 'clip.webp', { end: 4 }); // animated WebP

GIFs are capped at 256 colours, so toAnimation generates an optimal palette per clip and reuses it — much better than FFmpeg's default fixed palette. Keep fps, width and the range small to keep the file light.

Raw FFmpeg — run

The escape hatch for anything the typed operations don't cover. Pass an arbitrary argument list straight to ffmpeg and still get progress parsing, cancellation, timeout and the typed error hierarchy. Arguments are forwarded verbatim — you own the inputs, the output, and any -y to overwrite:

import { run } from 'ffm-script';

await run(['-i', 'input.mp4', '-vf', 'scale=1280:-2', '-crf', '18', '-y', 'out.mp4'], {
  duration: 124, // optional, enables the progress percentage
  onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`),
  timeout: 60_000,
});

For a progress percentage, pass the media duration — the input is not auto-probed, since there's no reliable way to tell which token is the input in a free-form argument list. Without it the run still works, it just emits no progress.

Streaming — runStream

The streaming counterpart to run, for very large files. Pipe a Node Readable into FFmpeg's stdin and/or its stdout into a Writable — the data flows straight through the process without being buffered in memory, so the footprint stays bounded whatever the file size. Reference the piped ends as pipe:0 / pipe:1 in the args:

import { runStream } from 'ffm-script';
import { createReadStream, createWriteStream } from 'node:fs';

await runStream(
  [
    '-i',
    'pipe:0',
    '-c:v',
    'libx264',
    '-movflags',
    'frag_keyframe+empty_moov',
    '-f',
    'mp4',
    'pipe:1',
  ],
  {
    input: createReadStream('big.mov'),
    output: createWriteStream('out.mp4'),
    onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`), // needs duration
  },
);

input and output are both optional — omit either to read from / write to a file path in the args instead. Common cases: transcode an incoming HTTP upload to a response ({ input: req, output: res }), or read a file and stream the result somewhere.

Pipes can't seek. FFmpeg can't rewind a stream, so the args must use a streamable format: a streamable container (MPEG-TS, Matroska) or fragmented MP4 (-movflags frag_keyframe+empty_moov) for piped output, and a linearly-decodable input for piped input. A plain moov-at-end MP4 can be neither read from nor written to a pipe. This is the same escape-hatch contract as run — you own the args.

It resolves once the process exits and the sink has flushed; it rejects with FFmpegError on a non-zero exit (or the underlying error if a stream fails), and supports signal and timeout like every other operation.

Progress

convert and trim accept an onProgress callback. The percentage is parsed from FFmpeg's output against the known duration:

await convert('input.mp4', 'output.mp4', {
  onProgress: ({ percent, currentTime, totalTime }) => {
    console.log(`${percent.toFixed(1)}% — ${currentTime}/${totalTime}s`);
  },
});

Cancellation

Every operation accepts an AbortSignal. Aborting kills the FFmpeg process and rejects with an AbortError:

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);

await convert('input.mp4', 'output.mp4', { signal: controller.signal });

Error handling

All errors extend FfmScriptError, so you can catch the base class or narrow by type:

import {
  FfmScriptError,
  FFmpegNotFoundError,
  FileNotFoundError,
  InvalidFormatError,
  InvalidOptionsError,
  FFmpegError,
  FFmpegTimeoutError,
} from 'ffm-script';

try {
  await probe('video.mp4');
} catch (err) {
  if (err instanceof FFmpegNotFoundError) console.error(err.message); // install instructions included
  if (err instanceof FFmpegError) {
    console.error(err.exitCode); // FFmpeg's exit code
    console.error(err.stderr); // raw FFmpeg stderr
  }
}

| Error | Thrown when | | --------------------- | ----------------------------------------------------------- | | FFmpegNotFoundError | ffmpeg/ffprobe cannot be located | | FileNotFoundError | the input file does not exist | | InvalidFormatError | the file extension is not supported | | InvalidOptionsError | options are invalid (bad timestamp, range, width…) | | FFmpegError | FFmpeg exited with a non-zero code (.stderr, .exitCode) | | FFmpegTimeoutError | a process exceeded its timeout (.duration) |

Inputs are validated before FFmpeg is ever spawned (file existence, extension, timestamps), so you get fast, typed errors instead of parsing FFmpeg's stderr.

AI agent skill

The package ships an Agent Skill (skills/ffm-script/SKILL.md) that teaches your AI coding agent this library's exact API — signatures, option names, format constraints, the typed error hierarchy and ready-made recipes — so it stops guessing or hallucinating options. It uses the shared Agent Skills format, so the same skill works with Claude Code, Codex, Cursor and 70+ other agents.

Install with the skills CLI (recommended)

The cross-agent installer pulls the skill straight from GitHub and drops it into the right folder for whichever agent you use:

npx skills add Doud75/ffm-script

Manual install (from your installed dependency)

If you prefer to use the copy versioned with the package you installed (always matching your API version), copy SKILL.md into your agent's skills folder, e.g. for Claude Code:

mkdir -p .claude/skills/ffm-script
cp node_modules/ffm-script/skills/ffm-script/SKILL.md .claude/skills/ffm-script/

For Codex or Cursor, use .agents/skills/ffm-script/ instead. The agent then loads it automatically when you work with ffm-script code.

Contributing

Contributions are welcome! See CONTRIBUTING.md for the dev setup, project layout, branch and commit conventions, and the checks CI expects before a pull request can be merged. Bugs and feature requests go through the issue templates. Participation is governed by our Code of Conduct.

Security

Found a vulnerability? Please don't open a public issue — report it privately as described in SECURITY.md, which also covers what's in scope for a wrapper around the FFmpeg binaries.

License

MIT