npm.io
0.11.0 • Published 3h ago

ffm-script

Licence
MIT
Version
0.11.0
Deps
0
Size
561 kB
Vulns
0
Weekly
0
Stars
4

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.
  • Focused APIprobe, convert, trim, extractAudio, thumbnail.
  • Progress & cancellationonProgress callbacks and AbortSignal support.
  • Typed errors — catch exactly what went wrong.

Why this exists

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 fills that gap with 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; thumbnails target JPEG/PNG.

Usage

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. Each maps to a libx264 CRF (the quality/size dial) and speed preset:

await convert('input.mp4', 'output.mp4', { quality: 'high' });
Preset FFmpeg Use it for
high -crf 18 -preset slow Visually lossless, larger files
balanced -crf 23 -preset medium Sensible default trade-off
small -crf 28 -preset medium Smaller files, lower quality

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

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',
});
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/
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)}%`) });

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,
  targetBitrate: '2000k',
  width: 1280, // height auto-scaled to preserve aspect ratio
  onProgress: (p) => console.log(`${p.percent.toFixed(0)}%`),
});

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. A value above the core count is capped to it.

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.

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

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, // 01; 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.

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.

License

MIT

Keywords