Fully typed TypeScript wrapper for FFmpeg — fluent builder API, v6/v7/v8 compatible, zero native bindings
mediaforge is a zero-dependency TypeScript library that wraps the system ffmpeg binary with a fluent, fully-typed API. No native bindings, no bundled binaries — it uses whatever ffmpeg is installed on the system.
import { ffmpeg } from 'mediaforge'; // ESM ✅
// or
const { ffmpeg } = require('mediaforge'); // CJS ✅
await ffmpeg('input.mp4')
.output('output.mp4')
.videoCodec('libx264')
.videoBitrate('2M')
.audioCodec('aac')
.audioBitrate('128k')
.run();npm / pnpm / yarn / bun
npm install mediaforge
pnpm add mediaforge
yarn add mediaforge
bun add mediaforgecli
npm install -g mediaforgedeno
deno add jsr:@globaltech/mediaforgeDeno (via JSR)
import { ffmpeg } from "jsr:@globaltech/mediaforge";Deno (via npm compat)
import { ffmpeg } from "npm:mediaforge";Requires ffmpeg (and ffprobe) to be installed and on PATH, or set FFMPEG_PATH / FFPROBE_PATH environment variables.
| Runtime | Supported | Notes |
|---|---|---|
| Node.js 20+ | ✅ | Recommended |
| Deno 2.x | ✅ | Via node: compat layer — requires --allow-env and --allow-run |
| Bun | ✅ | Full support |
| npm | ✅ | Full support |
| pnpm | ✅ | Full support |
| yarn | ✅ | Full support |
Deno users: the library uses
node:child_processandnode:eventsinternally. You must grant--allow-env(forFFMPEG_PATH/FFPROBE_PATHresolution) and--allow-run(for spawning theffmpeg/ffprobebinaries) when running your script:deno run --allow-env --allow-run my-script.ts
- What is mediaforge?
- Install
- Runtime Support
- Fluent Builder API
- Screenshots & Frame Extraction
- Pipe & Stream I/O
- Concat & Merge
- Animated GIF
- Audio Normalization
- Watermarks
- Subtitles
- Metadata
- Waveform & Spectrum
- Codec Serializers
- Color Grading & Visual Filters
- Edit Helpers
- Analysis Helpers
- Hardware Codec Helpers
- Audio Filters
- Quality Metrics
- HDR → SDR Tone Mapping
- Temporal Editing
- Hardware Filter Chains
- Subtitle Conversion
- ABR Ladder
- Named Presets
- HLS & DASH Packaging
- Two-Pass Encoding
- Arg-Builder Functions
- Stream Mapping DSL
- Hardware Acceleration
- Low-level Filter Graph API
- Filter System
- FFprobe Integration
- Process Management
- Progress Events
- Deno & Bun Usage
- CLI
- Compatibility Guards
- Version Support
- Environment Variables
All methods return this for chaining. Call .output() before codec/filter options.
import { ffmpeg } from 'mediaforge';
// Transcode video
await ffmpeg('input.mp4')
.output('output.mp4')
.videoCodec('libx264')
.crf(22)
.addOutputOption('-preset', 'fast')
.audioCodec('aac')
.audioBitrate('128k')
.run();
// Extract audio only
await ffmpeg('video.mp4')
.output('audio.mp3')
.noVideo()
.audioCodec('libmp3lame')
.audioBitrate('192k')
.run();
// Multiple outputs in one pass
await ffmpeg('input.mp4')
.output('preview.mp4')
.size('640x360')
.videoCodec('libx264')
.output('hq.mp4')
.size('1920x1080')
.videoCodec('libx264')
.run();| Method | Description |
|---|---|
.input(path, opts?) |
Add input file |
.seekInput(pos) |
Seek in last-added input (fast, emitted before -i) |
.inputDuration(d) |
Limit last-added input duration |
.inputFormat(fmt) |
Force input format (-f before -i) |
.inputFps(rate) |
Set input frame rate (-r before -i, for raw inputs) |
| Method | Description |
|---|---|
.output(path, opts?) |
Add output — call before codec/filter options |
.outputFormat(fmt) |
Force output format (-f) |
.map(spec) |
Add stream mapping |
.duration(d) |
Limit output duration |
.seekOutput(pos) |
Output seek (accurate, re-encode) |
.addOutputOption(...args) |
Pass extra output args |
| Method | Description |
|---|---|
.videoCodec(codec) |
Set video codec (libx264, libx265, copy, …) |
.videoBitrate(rate) |
Set video bitrate (2M, 4000k, …) |
.fps(rate) |
Set output frame rate |
.size(wxh) |
Set output size (1280x720) |
.crf(value) |
Set CRF quality value |
.pixelFormat(fmt) |
Set pixel format (-pix_fmt) |
.noVideo() |
Disable video stream (-vn) |
.videoFilter(f) |
Set -vf filter chain — call once, see note below |
.preset(value) |
Encoder speed/quality preset (-preset, e.g. slow, fast) |
.profile(value) |
H.264/H.265 profile (-profile:v, e.g. high, main) |
.level(value) |
Codec level (-level:v, e.g. 4.0) |
.movflags(flags) |
MP4/MOV container flags (-movflags, e.g. +faststart) |
.keyframeInterval(frames) |
Max GOP size (-g); the keyframe interval HLS needs |
.fpsMode(mode) |
cfr | vfr | passthrough | auto. Emits -vsync on ffmpeg < 5.1 and -fps_mode from 5.1 on |
.rateControl(opts) |
VBV bounds: { min?, max, bufferSize? } → -minrate / -maxrate / -bufsize. bufferSize defaults to max; max is required |
.setColorProperties(props) |
Tag output colour (-color_primaries, -color_trc, -colorspace, -color_range). Throws on an unknown key or an empty object |
| Method | Description |
|---|---|
.audioCodec(codec) |
Set audio codec (aac, libopus, copy, …) |
.audioBitrate(rate) |
Set audio bitrate (128k, 192k, …) |
.audioSampleRate(hz) |
Set sample rate (-ar) |
.audioChannels(n) |
Set channel count (-ac) |
.noAudio() |
Disable audio stream (-an) |
.audioFilter(f) |
Set -af filter chain — call once, see note below |
| Method | Description |
|---|---|
.subtitleCodec(codec) |
Set subtitle codec (-c:s) |
.noSubtitle() |
Disable subtitle stream (-sn) |
| Method | Description |
|---|---|
.complexFilter(f) |
Set -filter_complex. Only the last call takes effect. |
.hwAccel(name, opts?) |
Enable hardware acceleration (-hwaccel, optional { device }) |
.addGlobalOption(...args) |
Pass extra global args (before any -i) |
.overwrite(bool) |
Overwrite output (default: true) |
.logLevel(level) |
Set ffmpeg log level |
.enableProgress() |
Add -progress pipe:2 so progress events are emitted |
.setBinary(path) |
Override the ffmpeg binary (also clears cached version/registry) |
Call
.videoFilter()/.audioFilter()at most once. Each call appends another-vf/-afpair, and FFmpeg only applies the last one. Join your filters with commas instead — see the Filter System section.
| Method | Description |
|---|---|
.getVersion() |
Probe and cache the ffmpeg version (VersionInfo) |
.getRegistry() |
Probe and cache the CapabilityRegistry |
.checkCodec(codec, dir?) |
{ available, reason?, alternative? } — never throws |
.checkHwaccel(name) |
{ available, reason?, alternative? } — never throws |
.checkFeature(key) |
Version-gated feature check |
.selectVideoCodec(candidates) |
First available codec, or null |
.selectHwaccel(names) |
First available hwaccel, or null |
.versionString() |
Human-readable version string (Promise<string>) |
.buildArgs() |
Full argument array (same as .dry()) |
.dry() |
Return CLI args without executing |
.dryCommand() |
Return a shell-quoted command line for logging |
.spawn(opts?) |
Start process, return FFmpegProcess. Options: { parseProgress?, totalDurationUs?, timeout? } |
.run(opts?) |
Start process, return Promise<void>. Options: { parseProgress?, totalDurationUs?, timeout? } |
import { spawnFFmpeg, runFFmpeg, FFmpegSpawnError } from 'mediaforge';
// spawnFFmpeg — returns FFmpegProcess with .emitter for streaming events
const proc = spawnFFmpeg({ binary: 'ffmpeg', args: ['-i', 'in.mp4', 'out.mp4'] });
proc.emitter.on('end', () => console.log('done'));
// runFFmpeg — awaitable, throws FFmpegSpawnError on non-zero exit
await runFFmpeg({ binary: 'ffmpeg', args: ['-i', 'in.mp4', 'out.mp4'] });import { screenshots, frameToBuffer } from 'mediaforge';
// Extract 5 evenly-spaced screenshots
const { files } = await screenshots({
input: 'video.mp4',
folder: './thumbs',
count: 5,
});
console.log(files); // ['./thumbs/screenshot_0001.png', ...]
// Extract at specific timestamps
const { files } = await screenshots({
input: 'video.mp4',
folder: './thumbs',
timestamps: ['00:00:05', '00:01:30', 90],
filename: 'thumb_%04d.jpg',
size: '640x360',
});
// Get a single frame as a Buffer (no file written)
const buf = await frameToBuffer({
input: 'video.mp4',
timestamp: 30,
format: 'png',
size: '1280x720',
});
fs.writeFileSync('frame.png', buf);
// Export ALL frames as individual images with fps control
await extractFrames({
input: 'video.mp4',
folder: './frames',
fps: 30, // Extract at 30 fps (every frame)
filename: 'frame_%06d.png', // NOTE: the option is `filename`, not `pattern`
size: '1920x1080',
format: 'png', // 'png' | 'jpg' | 'bmp' | 'tiff'
startTime: 5,
endTime: 15, // Only used together with startTime
});
// Returns { files, firstFrame, lastFrame }import { pipeThrough, streamOutput, streamToFile } from 'mediaforge';
import fs from 'fs';
// Pipe: readable stream → ffmpeg → writable stream
// When outputFormat is 'mp4' or 'mov', the library automatically injects
// -movflags frag_keyframe+empty_moov+default_base_moof so the output is
// streamable without seeking. You do not need to set this manually.
const proc = pipeThrough({
inputFormat: 'webm',
outputArgs: ['-c:v', 'libx264', '-c:a', 'aac'],
outputFormat: 'mp4',
});
fs.createReadStream('input.webm').pipe(proc.stdin!);
proc.stdout.pipe(fs.createWriteStream('output.mp4'));
await new Promise((res, rej) => {
proc.emitter.on('end', res);
proc.emitter.on('error', rej);
});
// Stream output to HTTP response
import http from 'http';
http.createServer((req, res) => {
res.setHeader('Content-Type', 'video/mp4');
streamOutput({
input: 'movie.mp4',
outputFormat: 'mp4',
outputArgs: ['-c', 'copy', '-movflags', 'frag_keyframe+empty_moov'],
}).pipe(res);
}).listen(3000);
// Pipe incoming HTTP upload directly to file
await streamToFile({
stream: req, // Node.js IncomingMessage
inputFormat: 'webm',
output: './uploads/video.mp4',
outputArgs: ['-c:v', 'libx264', '-c:a', 'aac'],
});MP4/MOV pipe fixes: Two automatic fixes are applied when piping MP4/MOV:
- Output:
-movflags frag_keyframe+empty_moov+default_base_moofis injected automatically formp4/movoutput formats so the stream is seekable-free. Pass your own-movflagsto override.- Input:
-analyzeduration 100M -probesize 100Mis injected automatically wheninputFormatismp4/mov/m4v, giving FFmpeg enough buffer to locate themoovatom even when it is at the end of the file. For large files, pre-processing with-movflags +faststart(moov at start) is still recommended.
import { mergeToFile, concatFiles } from 'mediaforge';
// Stream copy (fastest — no re-encode)
await mergeToFile({
inputs: ['part1.mp4', 'part2.mp4', 'part3.mp4'],
output: 'merged.mp4',
});
// Re-encode while merging
await mergeToFile({
inputs: ['clip1.mp4', 'clip2.mp4'],
output: 'merged.mp4',
reencode: true,
videoCodec: 'libx264',
audioCodec: 'aac',
});
// filter_complex concat (event-based control) — now async, probes inputs for audio presence
const proc = await concatFiles({
inputs: ['a.mp4', 'b.mp4', 'c.mp4'],
output: 'out.mp4',
});
proc.emitter.on('progress', console.log);
await new Promise((res, rej) => {
proc.emitter.on('end', res);
proc.emitter.on('error', rej);
});
// Concat with crossfade transitions between clips
await concatWithTransitions({
inputs: ['intro.mp4', 'segment1.mp4', 'segment2.mp4', 'outro.mp4'],
output: 'seamless.mp4',
transition: 'fade',
duration: 1, // 1 second transition between each clip
});import { toGif, gifToMp4 } from 'mediaforge';
// High-quality 2-pass GIF (palette generation)
await toGif({
input: 'clip.mp4',
output: 'clip.gif',
width: 480,
fps: 15,
colors: 256,
dither: 'bayer',
startTime: 10,
duration: 5,
});
// Convert GIF back to MP4 (for platform uploads)
await gifToMp4({ input: 'animation.gif', output: 'animation.mp4' });import { normalizeAudio, adjustVolume } from 'mediaforge';
// EBU R128 two-pass normalization (broadcast standard)
const result = await normalizeAudio({
input: 'raw.mp4',
output: 'normalized.mp4',
targetI: -23, // integrated loudness (LUFS)
targetLra: 7, // loudness range (LU)
targetTp: -2, // true peak (dBTP)
twoPass: true, // default
});
console.log(`Input was ${result.inputI} LUFS`);
// With twoPass: false the source is never measured, so the input_* fields are
// NaN (genuinely unknown) rather than a fabricated value.
const onePass = await normalizeAudio({ input: 'a.mp4', output: 'b.mp4', twoPass: false });
console.log(Number.isNaN(onePass.inputI)); // true
// Podcast standard (-16 LUFS)
await normalizeAudio({ input: 'episode.mp3', output: 'episode-norm.mp3', targetI: -16 });
// Simple volume adjust
await adjustVolume({ input: 'in.mp4', output: 'out.mp4', volume: '0.5' }); // half
await adjustVolume({ input: 'in.mp4', output: 'out.mp4', volume: '6dB' }); // +6dB
// Detect silence and get timestamp ranges.
// Returns SilenceSegment[] — an array of { start, end, duration }, not an object.
const silences = await detectSilence({
input: 'audio.wav',
threshold: -40, // dB threshold (default: -50)
duration: 2, // minimum silence length in seconds (default: 0.5)
});
for (const s of silences) {
console.log(`Silent ${s.start}s → ${s.end}s (${s.duration}s)`);
}
// Two-pass EBU R128 measurement.
// `mode: 'file'` (default) probes the file; `mode: 'output'` parses a string
// you already captured from a loudnorm run.
const loudness = await parseLoudnorm({ input: 'audio.mp3' });
console.log(loudness.inputI); // integrated loudness, LUFS
console.log(loudness.inputLra); // loudness range, LU
console.log(loudness.inputTp); // true peak, dBTP
console.log(loudness.inputThresh); // threshold, LUFSimport { addWatermark, addTextWatermark } from 'mediaforge';
// Image watermark
await addWatermark({
input: 'video.mp4',
watermark: 'logo.png',
output: 'watermarked.mp4',
position: 'bottom-right', // top-left | top-right | top-center |
// bottom-left | bottom-right | bottom-center | center
margin: 10,
opacity: 0.7,
scaleWidth: 150, // optional: scale logo to 150px wide
});
// Text watermark
await addTextWatermark({
input: 'video.mp4',
output: 'watermarked.mp4',
text: '© MyCompany 2026',
position: 'bottom-right',
fontSize: 24,
fontColor: 'white@0.8',
fontFile: '/path/to/font.ttf', // optional
});import { burnSubtitles, extractSubtitles } from 'mediaforge';
// Burn (hardcode) subtitles into video
await burnSubtitles({
input: 'video.mp4',
subtitleFile: 'subs.srt',
output: 'video-subbed.mp4',
fontSize: 24,
fontName: 'Arial',
});
// Extract subtitle stream to file
await extractSubtitles({
input: 'movie.mkv',
output: 'subs.srt',
streamIndex: 0,
});import { writeMetadata, stripMetadata } from 'mediaforge';
// Write container and stream metadata
await writeMetadata({
input: 'video.mp4',
output: 'tagged.mp4',
metadata: { title: 'My Film', artist: 'Director', year: '2025', comment: 'Draft' },
streamMetadata: {
'a:0': { language: 'eng', title: 'English Audio' },
's:0': { language: 'fra' },
},
chapters: [
{ title: 'Introduction', startSec: 0, endSec: 120 },
{ title: 'Act One', startSec: 120, endSec: 1800 },
],
});
// Strip all metadata (privacy-safe export)
await stripMetadata({ input: 'original.mp4', output: 'clean.mp4' });
// Add chapter markers (convenience wrapper)
// Each chapter only needs `start`; the end time of each chapter is taken from
// the next chapter's start, and the last one ends at the media duration.
// `startSec` is accepted as an alias for `start`. Chapters must be supplied in
// ascending time order — addChapters() throws if they are not.
await addChapters({
input: 'movie.mp4',
output: 'chapters.mp4',
chapters: [
{ title: 'Introduction', start: 0 },
{ title: 'Chapter 1: Setup', start: 120 },
{ title: 'Chapter 2: Action', start: 600 },
{ title: 'Conclusion', start: 1800 },
],
});import { generateWaveform, generateSpectrum } from 'mediaforge';
// Waveform image from audio
await generateWaveform({
input: 'audio.mp3',
output: 'waveform.png',
width: 1920,
height: 240,
color: '#00aaff', // leading '#' is stripped before it reaches showwavespic
scale: 'lin', // 'lin' | 'log'
streamIndex: 0,
});
// Real-time spectrum visualizer video
await generateSpectrum({
input: 'podcast.mp3',
output: 'spectrum.mp4',
width: 1280,
height: 720,
color: 'fire', // see the palette list below — NOT a CSS colour
fps: 25,
});
generateSpectrumcolour is a palette, not a CSS colour. UnlikegenerateWaveform(whose colour is a CSS colour forshowwavespic), theshowspectrumfilter'scoloroption is an integer enum of named palettes. Passing'red'or'#ff0000'makes ffmpeg abort withUndefined constant or missing '(' in 'red'. The valid values are exported asSPECTRUM_COLORSand typed asSpectrumColor:channel,intensity,rainbow,moreland,nebulae,fire,fiery,fruit,cool,magma,green,viridis,plasma,cividis,terrain. Note thatgreenis a valid palette name whileredis not — an easy trap.
FFmpeg 7.x compatibility: the
showwavespicfilter'sbgcoloranddrawparameters were removed in FFmpeg 7.1, sogenerateWaveformnever emits them. ThebackgroundColorandmodeoptions are deprecated no-ops: they are still accepted so existing code keeps type-checking, but they have no effect and passing them logs a deprecation warning. Remove them for forward compatibility. ThevideoFilter-style builderbuildWaveformFilter()takes the colour verbatim (no#stripping), and itsstreamIndexdefaults to the first audio stream, so a four-argument call still produces a usable filter rather than[0:a:undefined].
Typed helpers that build the exact FFmpeg argument arrays for each encoder. Every helper is verified against both FFmpeg v7 (Ubuntu/Linux) and FFmpeg v8 (Android Termux).
import {
x264ToArgs, x265ToArgs, svtav1ToArgs, vp9ToArgs,
proResToArgs, dnxhdToArgs, mjpegToArgs, mpeg2ToArgs,
mpeg4ToArgs, vp8ToArgs, theoraToArgs, ffv1ToArgs,
} from 'mediaforge';
// H.264 — libx264
await ffmpeg('in.mp4').output('out.mp4').addOutputOption(...x264ToArgs({ crf: 22, preset: 'slow' })).run();
// Apple ProRes HQ — prores_ks
await ffmpeg('in.mp4').output('out.mov').addOutputOption(...proResToArgs({ profile: 3 })).run();
// Avid DNxHD
await ffmpeg('in.mp4').output('out.mxf').addOutputOption(...dnxhdToArgs({ bitrate: 145, pixFmt: 'yuv422p10le' })).run();
// Motion JPEG
await ffmpeg('in.mp4').output('out.avi').addOutputOption(...mjpegToArgs({ qscale: 3 })).run();
// MPEG-2 (broadcast / DVD)
await ffmpeg('in.mp4').output('out.mpg').addOutputOption(...mpeg2ToArgs({ bitrate: 8000, interlaced: true })).run();
// VP8 (WebM)
await ffmpeg('in.mp4').output('out.webm').addOutputOption(...vp8ToArgs({ bitrate: 800, cpuUsed: 4 })).run();
// FFV1 lossless archival
await ffmpeg('in.mp4').output('out.mkv').addOutputOption(...ffv1ToArgs({ level: 3, slices: 16, sliceCrc: true })).run();| Helper | Encoder | Min FFmpeg |
|---|---|---|
x264ToArgs(opts) |
libx264 |
v6 |
x265ToArgs(opts) |
libx265 |
v6 |
svtav1ToArgs(opts) (alias svtAv1ToArgs) |
libsvtav1 |
v6 |
vp9ToArgs(opts) |
libvpx-vp9 |
v6 |
proResToArgs(opts?, enc?) |
prores_ks / prores_aw / prores |
v6 |
dnxhdToArgs(opts?) |
dnxhd |
v6 |
mjpegToArgs(opts?) |
mjpeg |
v6 |
mpeg2ToArgs(opts?) |
mpeg2video |
v6 |
mpeg4ToArgs(opts?, enc?) |
mpeg4 / libxvid |
v6 |
vp8ToArgs(opts?) |
libvpx |
v6 |
theoraToArgs(opts?) |
libtheora |
v6 |
ffv1ToArgs(opts?) |
ffv1 |
v6 |
import {
aacToArgs, mp3ToArgs, opusToArgs, flacToArgs, ac3ToArgs,
alacToArgs, eac3ToArgs, truehdToArgs, vorbisToArgs, wavpackToArgs,
pcmToArgs, mp2ToArgs,
} from 'mediaforge';
// Apple Lossless
await ffmpeg('in.flac').output('out.m4a').addOutputOption(...alacToArgs()).run();
// Dolby Digital Plus (streaming platforms)
await ffmpeg('in.mp4').output('out.mp4').addOutputOption(...eac3ToArgs({ bitrate: 640, dialNorm: -24 })).run();
// PCM master (WAV)
await ffmpeg('in.mp4').output('out.wav').addOutputOption(...pcmToArgs('pcm_s24le', { sampleRate: 48000 })).run();
// MP2 broadcast
await ffmpeg('in.mp4').output('out.mpg').addOutputOption(...mp2ToArgs({ bitrate: 192, sampleRate: 48000 })).run();
mp3ToArgsandopusToArgsare also exported under the aliaseslibMp3LameToArgsandlibOpusToArgs.
| Helper | Encoder | Use case | Options |
|---|---|---|---|
aacToArgs(opts) |
aac |
Universal streaming | aacCoder, vbr, bitrate, sampleRate, channels, channelLayout, sampleFmt, profile |
mp3ToArgs(opts) |
libmp3lame |
Consumer / podcasting | qscale, bitrate, compressionLevel, reservoir, jointStereo, abr, sampleRate, channels |
opusToArgs(opts) |
libopus |
WebRTC, Discord | bitrate, vbr, application, frameDuration, compressionLevel, packetLoss, fec, dtx, channels, sampleRate |
flacToArgs(opts?) |
flac |
Lossless | compressionLevel, lpcOrder, lpcCoeffPrecision, predictionOrderMethod, minPartitionOrder, maxPartitionOrder |
ac3ToArgs(opts?) |
ac3 |
Dolby Digital | bitrate, dialogueLevel, centerMixLevel, surroundMixLevel, audioCodingMode, channels, sampleRate |
alacToArgs(opts?) |
alac |
Apple Lossless | minPredictionOrder, maxPredictionOrder |
eac3ToArgs(opts?) |
eac3 |
Dolby Digital Plus (Netflix/Amazon) | bitrate, dialNorm, mixLevel, roomType, centerMixLevel, surroundMixLevel |
truehdToArgs(opts?) |
truehd |
Dolby TrueHD (Blu-ray) | sampleRate, channelLayout |
vorbisToArgs(opts?) |
libvorbis |
Ogg Vorbis | qscale, bitrate, minrate, maxrate |
wavpackToArgs(opts?) |
wavpack |
Hybrid lossless | quality, bitrate, extra |
pcmToArgs(format, opts?) |
pcm_s16le, pcm_s24le, pcm_f32le, … |
Raw PCM masters | sampleRate, channels |
mp2ToArgs(opts?) |
mp2 |
DVB/ATSC broadcast | bitrate, sampleRate |
import { nvencToArgs, vaapiToArgs, qsvToArgs, mediacodecVideoToArgs, vulkanVideoToArgs } from 'mediaforge';
// NVIDIA NVENC (Linux)
await ffmpeg('in.mp4').output('out.mp4').addOutputOption(...nvencToArgs({ preset: 'p4', cq: 23 }, 'h264_nvenc')).run();
// Android MediaCodec (FFmpeg v8 / Termux)
await ffmpeg('in.mp4').output('out.mp4').addOutputOption(...mediacodecVideoToArgs({ bitrate: 4000 }, 'h264_mediacodec')).run();
// Vulkan GPU (Linux + Android)
await ffmpeg('in.mp4').output('out.mp4').addOutputOption(...vulkanVideoToArgs({ crf: 22 }, 'h264_vulkan')).run();| Helper | Codecs | Platform |
|---|---|---|
nvencToArgs(opts, codec?) |
h264_nvenc, hevc_nvenc, av1_nvenc |
NVIDIA GPU (Linux) |
vaapiToArgs(opts, codec?) |
h264_vaapi, hevc_vaapi, vp8_vaapi, … |
Intel/AMD (Linux) |
qsvToArgs(opts, codec?) |
h264_qsv, hevc_qsv |
Intel Quick Sync |
mediacodecToArgs(opts, codec?) |
h264_mediacodec, hevc_mediacodec |
Android MediaCodec (generic) |
mediacodecVideoToArgs(opts, codec?) |
h264_mediacodec, hevc_mediacodec, av1_mediacodec, … |
Android (FFmpeg v8) |
vulkanToArgs(opts, codec?) |
h264_vulkan, hevc_vulkan |
Vulkan GPU (generic) |
vulkanVideoToArgs(opts, codec?) |
h264_vulkan, hevc_vulkan, av1_vulkan, ffv1_vulkan |
Vulkan GPU |
import { trimVideo } from 'mediaforge';
// Instant stream copy (frame-approximate, no quality loss)
await trimVideo({ input: 'in.mp4', output: 'out.mp4', start: 10, end: 40 });
// Frame-accurate re-encode
await trimVideo({ input: 'in.mp4', output: 'out.mp4', start: '00:00:10', duration: 30, copy: false });import { changeSpeed } from 'mediaforge';
await changeSpeed({ input: 'in.mp4', output: 'fast.mp4', speed: 2.0 }); // 2× faster
await changeSpeed({ input: 'in.mp4', output: 'slow.mp4', speed: 0.25 }); // 4× slow-mo
// Chains multiple atempo filters automatically for speeds outside 0.5–2.0import { extractAudio, replaceAudio, mixAudio } from 'mediaforge';
// Extract audio — codec auto-detected from extension
await extractAudio({ input: 'video.mp4', output: 'audio.mp3' });
await extractAudio({ input: 'video.mp4', output: 'master.wav', sampleRate: 48000 });
// Replace audio track
await replaceAudio({ video: 'video.mp4', audio: 'music.mp3', output: 'out.mp4' });
// Mix multiple audio files
await mixAudio({ inputs: ['voice.mp3', 'bg.mp3'], output: 'mixed.mp3', weights: [1.0, 0.3] });import { stackVideos } from 'mediaforge';
// Side by side
await stackVideos({ inputs: ['a.mp4', 'b.mp4'], output: 'side.mp4', direction: 'hstack' });
// 2×2 grid
await stackVideos({ inputs: ['a.mp4','b.mp4','c.mp4','d.mp4'], output: 'grid.mp4', direction: 'xstack', columns: 2 });import { cropToRatio } from 'mediaforge';
await cropToRatio({ input: 'wide.mp4', output: 'square.mp4', ratio: '1:1' });
await cropToRatio({ input: 'landscape.mp4', output: 'portrait.mp4', ratio: '9:16' });import { generateSprite } from 'mediaforge';
const info = await generateSprite({ input: 'video.mp4', output: 'sprites.jpg', columns: 5, count: 25 });
// info.columns, info.rows, info.thumbWidth — use to build a VTT seek-preview fileimport { buildAtempoChain } from 'mediaforge';
buildAtempoChain(2.0); // → 'atempo=2.000000'
buildAtempoChain(4.0); // → 'atempo=2.0,atempo=2.000000' (chained for >2x)
buildAtempoChain(0.25); // → 'atempo=0.5,atempo=0.500000' (chained for <0.5x)import { loopVideo, deinterlace, applyLUT, stabilizeVideo, streamToUrl } from 'mediaforge';
await loopVideo({ input: 'clip.mp4', output: 'looped.mp4', loops: 2, duration: 10 }); // loop 2x, trim to 10s total
await deinterlace({ input: 'broadcast.ts', output: 'progressive.mp4' });
await applyLUT({ input: 'raw.mp4', output: 'graded.mp4', lut: 'film.cube' });
await stabilizeVideo({ input: 'shaky.mp4', output: 'stable.mp4', smoothing: 15 });
// Requires FFmpeg compiled with --enable-libvidstab
// `format` is optional — it is inferred from the URL scheme
// (rtmp/rtmps → flv, srt/udp/rtp → mpegts, otherwise flv).
await streamToUrl({ input: 'video.mp4', url: 'rtmp://live.twitch.tv/app/STREAM_KEY' });import { curves, deband, deshake, deflicker, smartblur, FilterChain } from 'mediaforge';
// Dual-style — works standalone (returns a filter string) or chained
await ffmpeg('in.mp4').output('out.mp4').videoFilter(curves({ preset: 'vintage' })).videoCodec('libx264').run();
// Chained-only — a FilterChain is required as the first argument
const chain = new FilterChain();
deband(chain);
deshake(chain, { rx: 16, ry: 16 });
deflicker(chain);
smartblur(chain);
await ffmpeg('in.mp4').output('out.mp4').videoFilter(chain.toString()).run();| Filter | Style | Description |
|---|---|---|
curves(opts) |
dual | Tone curves — named presets (vintage, cross_process, …) or custom R/G/B points |
levels(opts?) |
dual | Input/output range + gamma via FFmpeg's colorlevels filter (deprecated alias of colorlevels) |
deband(chain, opts?) |
chained | Remove banding from flat regions |
deshake(chain, opts?) |
chained | Camera shake stabilisation (no external lib) |
deflicker(chain, opts?) |
chained | Reduce temporal flicker (time-lapses) |
smartblur(chain, opts?) |
chained | Edge-preserving smoothing |
hstack(chain, n) |
chained | Stack N videos horizontally (use stackVideos for a high-level API) |
vstack(chain, n) |
chained | Stack N videos vertically |
xstack(chain, opts) |
chained | Arrange N videos in a custom grid |
colorSource(chain, opts?) |
chained | Solid colour source frame (for overlays) |
drawbox(opts?) |
dual | Draw colored boxes/frames on video |
drawgrid(opts?) |
dual | Draw a grid overlay |
vignette(opts?) |
dual | Apply vignette effect |
vaguedenoiser(opts?) |
dual | Wavelet-based denoising |
delogo(opts?) |
dual | Blur out a rectangular region (station logo, clock) |
levels()is a deprecated alias. It is the same function ascolorlevels(), and it serializes to FFmpeg'scolorlevelsfilter — not the unrelatedlevelsfilter added in FFmpeg 7.x.colorlevelsis available in FFmpeg 6 and 7, so there is no v6/v7 caveat:levels({ inBlack: 10, inWhite: 240 }); // → 'colorlevels=rimin=0.0392…:rimax=0.9411…'Prefer
curves()for tone work, or.addOutputOption('-vf', 'levels=...')if you specifically need the FFmpeg 7.xlevelsfilter.
import { detectSilence, detectScenes, cropDetect, burnTimecode, parseLoudnorm } from 'mediaforge';
// Detect silence segments (returns Array<{ start, end, duration }>)
const silences = await detectSilence({
input: 'podcast.mp3',
threshold: -40, // dB (default: -50)
duration: 0.5, // minimum silence length in seconds (default: 0.5)
});
silences.forEach(s => console.log(`Silence: ${s.start.toFixed(2)}s – ${s.end.toFixed(2)}s`));
// Two-pass EBU R128 loudness measurement (non-destructive)
const loudness = await parseLoudnorm({ input: 'audio.mp3' });
console.log(loudness); // { inputI, inputLra, inputTp, inputThresh } ← camelCaseimport { detectSilence, detectScenes, cropDetect, burnTimecode } from 'mediaforge';
// Detect scene changes in video
const scenes = await detectScenes({
input: 'video.mp4',
threshold: 0.4, // scene change sensitivity (default: 0.4, lower = more sensitive)
});
// Returns: Array of { timestamp: number, sceneNumber: number }
scenes.forEach(s => console.log(`Scene ${s.sceneNumber} at ${s.timestamp}s`));
// Detect letterbox/pillarbox (black bars)
const crop = await cropDetect({
input: 'video.mp4',
limit: 100, // max frames to scan (default: 100)
skip: 5, // skip first N seconds (default: 5)
});
if (crop) console.log(crop); // { width: 1920, height: 800, x: 0, y: 140 } or null
// Burn timecode into video
// position: 'tl', 'tr', 'bl', 'br', 'center' (default: 'bl')
await burnTimecode({
input: 'video.mp4',
output: 'timecoded.mp4',
fontsize: 24,
fontcolor: 'white',
position: 'bl',
});import { amfToArgs, videotoolboxToArgs } from 'mediaforge';
// AMD AMF (Windows/Linux with AMD GPU)
await ffmpeg('in.mp4').output('out.mp4').addOutputOption(...amfToArgs({ bitrate: 8000, quality: 'balanced' }, 'hevc_amf')).run();
// Apple VideoToolbox (macOS/iOS)
await ffmpeg('in.mp4').output('out.mp4').addOutputOption(...videotoolboxToArgs({ bitrate: 6000 }, 'hevc_videotoolbox')).run();| Helper | Codecs | Platform |
|---|---|---|
amfToArgs(opts, codec?) |
h264_amf, hevc_amf, av1_amf |
AMD GPU (Windows / Linux libamf) |
videotoolboxToArgs(opts, codec?) |
h264_videotoolbox, hevc_videotoolbox |
Apple macOS / iOS |
mediaforge exports 26 audio filters. They fall into two groups:
| Style | Filters | Usage |
|---|---|---|
| Dual (standalone and chained) | volume, loudnorm, equalizer, bass, treble, atempo, headphones, sofalizer |
bass({ gain: 4 }) → string, or bass(chain, { gain: 4 }) |
Chained only (FilterChain required as first argument) |
aecho, afade, agate, amerge, amix, aresample, asetpts, asplit, atrim, channelmap, channelsplit, compand, dynaudnorm, highpass, lowpass, pan, rubberband, silencedetect |
highpass(chain, 80) |
Calling a chained-only filter without a
FilterChainthrowsTypeError: Cannot read properties of undefined. For examplehighpass({ frequency: 80 })is not valid — usehighpass(chain, 80).
import {
bass, treble, equalizer, loudnorm, atempo, // dual
highpass, lowpass, agate, dynaudnorm, // chained only
rubberband, aecho, pan, aresample,
headphones, sofalizer,
FilterChain,
} from 'mediaforge';
// Standalone — the filter functions return strings, so join them yourself
await ffmpeg('in.mp4').output('out.mp4')
.audioFilter([
bass({ gain: 4 }), // → 'bass=g=4'
treble({ gain: -2 }), // → 'treble=g=-2'
equalizer({ frequency: 3000, width_type: 'o', width: 1, gain: 3 }),
loudnorm({ i: -16, lra: 11 }), // → 'loudnorm=i=-16:lra=11'
].join(','))
.audioCodec('aac').run();
// Chained-only filters need a FilterChain
const chain = new FilterChain();
highpass(chain, 80); // remove sub-80Hz rumble
lowpass(chain, 16000);
agate(chain, { threshold: 0.02 });
dynaudnorm(chain);
await ffmpeg('podcast.mp3').output('gated.mp3')
.audioFilter(chain.toString())
.audioCodec('libmp3lame').run();
// Tempo / pitch
await ffmpeg('in.mp4').output('out.mp4')
.audioFilter(atempo({ tempo: 1.5 })) // 1.5× speed (audio only)
.audioCodec('aac').run();
const rb = new FilterChain();
rubberband(rb, { pitch: 1.5 }); // pitch-shift up
await ffmpeg('in.mp4').output('out.mp4')
.audioFilter(rb.toString())
.audioCodec('aac').run();
// Spatial audio
const spat = new FilterChain();
aecho(spat, { delays: 500, decays: 0.5 });
await ffmpeg('in.mp4').output('out.mp4')
.audioFilter([
spat.toString(),
headphones({ map: 'stereo' }), // virtual surround
sofalizer({ sofa: 'HRTF_44100.sofa', gain: 3.0 }), // SOFA 3D audio
].join(','))
.audioCodec('aac').run();
// Channel manipulation (chained-only)
const chan = new FilterChain();
pan(chan, 'mono|c0=0.5*c0+0.5*c1');
aresample(chan, 44100);
await ffmpeg('stereo.mp3').output('mono.mp3')
.audioFilter(chan.toString())
.audioCodec('libmp3lame').run();Do not call
.audioFilter()/.videoFilter()more than once. Each call appends another-af/-vfpair to the argument list, and FFmpeg only honours the last one:// ✗ WRONG — produces ['-af','volume=2','-af','alimiter']; only alimiter runs ffmpeg('in.mp4').output('out.mp4').audioFilter('volume=2').audioFilter('alimiter'); // ✓ RIGHT — one joined filter ffmpeg('in.mp4').output('out.mp4').audioFilter('volume=2,alimiter');
Measure how much an encode lost, and gate a pipeline on it. ssim and psnr
work on any ffmpeg build; vmaf needs one compiled with libvmaf.
import { measureQuality, buildVmafFilter, parseStatsFile } from 'mediaforge';
// Compare an encode against its source
const score = await measureQuality({
reference: 'source.mp4',
distorted: 'encode.mp4',
metric: 'vmaf', // 'vmaf' | 'ssim' | 'psnr'
});
console.log(score.value, score.frames); // 94.21 over 300 frames
// Gate a build: throws when the score is below `minScore`
await measureQuality({ reference: 'a.mp4', distorted: 'b.mp4', minScore: 90 });A perfect encode scores Infinity on PSNR (ffmpeg reports psnr_avg:inf);
that is a real result, and it clears any minimum.
The filter strings and the log parsers are exported separately, so you can inspect or unit-test them without running an encoder:
buildVmafFilter({ target: 90, model: 'version=v0.6.1' });
// "libvmaf=log_fmt=json:target=90:model='version=v0.6.1'"
buildSsimFilter({ statsFile: 'out.log' }); // "ssim=stats_file='out.log'"
buildPsnrFilter(); // "psnr"
parseStatsFile(logContents, 'psnr');import { toneMapHdrToSdr, buildToneMapFilter } from 'mediaforge';
await toneMapHdrToSdr({
input: 'hdr.mp4',
output: 'sdr.mp4',
algorithm: 'hable', // hable | mobius | reinhard | clip | linear | spline
peak: 1000, // source peak luminance, nits
targetPeak: 100,
desaturation: 0.6,
});The input is probed first and a non-HDR source is refused — tone mapping an
already-SDR file is almost always a mistake. Pass requireHdrInput: false to
force it anyway. The output is tagged BT.709 so players do not re-apply an HDR
display transform on top of the result.
buildToneMapFilter() returns the filter chain on its own:
zscale=t=linear:npl=1000:p=bt2020:t=smpte2084:r=tv:m=bt2020nc,
tonemap=tonemap=hable:peak=1000:desat=0.6,
zscale=t=bt709:p=bt709:m=bt709:r=tv
zscale uses short option names.
tis transfer,pprimaries,mmatrix,rrange — andw/hare pixel size.width=bt2020would be read as a pixel size and rejected withInvalid size 'bt2020'.
Real motion-compensated interpolation — the correct way to make slow motion or convert 24 → 60, rather than duplicating frames.
import { interpolateFrames, buildInterpolateFilter } from 'mediaforge';
await interpolateFrames({
input: 'in.mp4', output: 'slow.mp4',
fps: 60,
method: 'mci', // 'mci' (motion-compensated) | 'blend' | 'dup'
mcMode: 'obmc', // mci only: 'obmc' | 'aobmc'
meMode: 'bidir', // mci only: 'bidir' | 'bilat'
});
buildInterpolateFilter({ fps: 60 });
// "minterpolate=fps=60:mi_mode=mci:mc_mode=obmc:me_mode=bidir:mb_size=8"mb_size / mc_mode are only emitted for mci; ffmpeg rejects them for
blend and dup.
import { detectScenes, cutToScenes, buildSceneCutArgs } from 'mediaforge';
const scenes = await detectScenes({ input: 'in.mp4', threshold: 0.4 });
// [{ timestamp: 12.4, sceneNumber: 1 }, …]
// Use the cuts as an edit decision list
await cutToScenes({ input: 'in.mp4', output: 'out.mp4', threshold: 0.4, trimStart: 0.2 });The windows are [0, s0], [s0, s1], … — the clip before the first boundary
runs from the start of the file, not from the boundary. trimStart shortens
each window from its end, which drops the static tail that usually follows a
hard cut. buildSceneCutArgs() returns the exact -ss/-to pairs.
import { detectSilence, removeSilence } from 'mediaforge';
const silences = await detectSilence({ input: 'in.mp3', threshold: -50, duration: 0.5 });
await removeSilence({ input: 'in.mp3', output: 'out.mp3', threshold: -50, minDuration: 0.5 });import { writeSegments, buildSegmentArgs } from 'mediaforge';
await writeSegments({ input: 'in.mp4', outputPattern: 'out/seg%03d.ts', segmentTime: 2 });The segment muxer can only cut on keyframes, so keyframes are forced at the
segment boundaries — without that a 2 s file with segmentTime: 1 quietly
comes out as a single segment. The output directory is created for you.
Build upload → GPU work → download chains so frames only cross the bus once.
import {
buildHwUploadFilter, buildHwScaleFilter,
buildHwDownloadFilter, buildHwFilterChain, HWACCELS,
} from 'mediaforge';
buildHwFilterChain({
accel: 'cuda',
gpuFilters: [buildHwScaleFilter({ accel: 'cuda', width: 1280, height: 720 })],
cpuFilters: ['drawtext=text=hi'],
downloadFormat: 'nv12',
});
// "hwupload,scale_cuda=w=1280:h=720,hwdownload=format=nv12,drawtext=text=hi"buildHwScaleFilter throws for accelerations ffmpeg has no GPU scaler for
(videotoolbox, the D3D/DXVA paths) rather than quietly emitting a software
scale you would think is accelerated — use buildHwDownloadFilter() plus a
software scale there. buildHwFilterChain also refuses an empty gpuFilters,
since uploading and downloading with no GPU work in between is always slower
than staying in software.
import { convertSubtitles, fixSubtitleDuration, subtitleCodecFor } from 'mediaforge';
// Convert an embedded track
await convertSubtitles({ input: 'in.mkv', output: 'out.srt', format: 'srt' });
// Rescale cue timings after a VFR → CFR or speed change
await fixSubtitleDuration({ input: 'in.mkv', output: 'out.srt' });
// Shift every cue later (mutually exclusive with fixDuration)
await convertSubtitles({ input: 'in.mkv', output: 'out.srt', shiftSeconds: 1.5 });
// Convert and burn in one pass
await convertSubtitles({ input: 'in.mkv', output: 'out.mp4', burn: true });
subtitleCodecFor('vtt'); // "webvtt" — ffmpeg has no bare "vtt" codecA multi-bitrate HLS ladder in a single ffmpeg pass using -var_stream_map.
Preferred over adaptiveHls() for anything needing per-language audio variants,
because the native mechanism handles the stream grouping that N-output +
master_pl_name cannot.
import { abrLadder, buildAbrLadderArgs, validateAbrVariants } from 'mediaforge';
await abrLadder({
input: 'in.mp4',
outputPattern: 'out/v%v/index.m3u8', // must contain %v
variants: [
{ name: '1080p', resolution: '1920x1080', videoBitrate: '5M', audioBitrate: '192k' },
{ name: '720p', resolution: '1280x720', videoBitrate: '2500k' },
{ name: '360p', resolution: '640x360', videoBitrate: '800k' },
],
segmentDuration: 6,
}).run();Variants must have even dimensions. HLS/MPEG-TS only carries even sizes in
yuv420p, and ffmpeg accepts the odd value here and then dies deep in the encoder
with maybe incorrect parameters such as bit_rate, rate, width or height, so
validateAbrVariants() rejects it up front — from every entry point.
masterPlaylist is passed to ffmpeg as a bare filename, not a path: ffmpeg
resolves -master_pl_name against the output directory and prepends it even to
an absolute path.
Production-ready codec configurations, ready to apply:
import { getPreset, applyPreset, listPresets } from 'mediaforge';
// Get preset as separate arg arrays
const p = getPreset('web');
await ffmpeg('input.mp4')
.output('output.mp4')
.addOutputOption(...p.videoArgs)
.addOutputOption(...p.audioArgs)
.run();
// Or as flat array
await ffmpeg('input.mp4')
.output('output.mp4')
.addOutputOption(...applyPreset('web'))
.run();
// List all available presets
console.log(listPresets());| Preset | Description |
|---|---|
web |
H.264 + AAC, faststart, browser-safe |
web-hq |
H.264 CRF 18 + AAC 192k, slow preset |
mobile |
H.264 baseline + AAC, small file |
archive |
Lossless H.264 CRF 0 + FLAC |
podcast |
Audio-only, mono AAC 96k, no video |
hls-input |
H.264 with fixed keyframes for HLS |
gif |
Audio disabled (use with toGif()) |
discord |
Discord-friendly H.264 + AAC |
instagram |
Instagram-compatible H.264 + AAC |
prores |
ProRes 422 HQ + PCM for editing |
dnxhd |
DNxHD 115 + PCM for editing |
import { hlsPackage, adaptiveHls, dashPackage } from 'mediaforge';
// Single-bitrate HLS
await hlsPackage({
input: 'input.mp4',
outputDir: './hls-output',
segmentDuration: 6,
playlistName: 'playlist.m3u8', // default
segmentFilename: 'segment%03d.ts', // default
hlsListSize: 0, // 0 = keep all segments (default)
gopSize: 48, // keyframe interval (default)
videoCodec: 'libx264',
videoBitrate: '2M',
audioBitrate: '128k',
// hlsVersion is a top-level muxer option (`-hls_version`, 3–8), NOT an
// hls_flags entry — `hlsFlags: 'hls_version=3'` makes ffmpeg abort.
hlsVersion: 3, // optional; ffmpeg picks a version when omitted
hlsFlags: 'delete_segments',
// hlsKeyInfoFile: './key.info', // AES-128 encryption key info
}).run();
// Adaptive HLS (multiple bitrates)
await adaptiveHls({
input: 'input.mp4',
outputDir: './hls-output',
variants: [
{ label: '1080p', resolution: '1920x1080', videoBitrate: '4M', audioBitrate: '192k' },
{ label: '720p', resolution: '1280x720', videoBitrate: '2M', audioBitrate: '128k' },
{ label: '360p', resolution: '854x480', videoBitrate: '800k', audioBitrate: '96k' },
],
}).run();
// DASH
await dashPackage({
input: 'input.mp4',
output: 'output/manifest.mpd',
segmentDuration: 4,
videoCodec: 'libx264',
videoBitrate: '2M',
}).run();FFmpeg option ordering: all
hls_*anddash_*flags are output-private options and must appear after-f hls/-f dashin the argument list, or FFmpeg rejects them withUnrecognized option.FFmpegBuilder.buildArgs()emits the output format before any extra output options, so ordering is handled for you.hlsFlags/dashFlagsare the escape hatches for anything not modelled as a first-class option.
import { twoPassEncode, buildTwoPassArgs } from 'mediaforge';
await twoPassEncode({
input: 'input.mp4',
output: 'output.mp4',
videoCodec: 'libx264',
videoBitrate: '2M',
audioCodec: 'aac',
audioBitrate: '128k',
onPass1Complete: () => console.log('Pass 1 done'),
});
// Inspect args without running
const { pass1, pass2 } = buildTwoPassArgs({
input: 'input.mp4',
output: 'output.mp4',
videoCodec: 'libvpx-vp9',
videoBitrate: '1.5M',
});Low-level helpers that return raw string[] arrays, useful when you need direct control over FFmpeg arguments. Each one corresponds to a high-level helper but exposes the underlying argument array.
import {
buildScreenshotArgs, buildFrameBufferArgs, buildExtractFramesArgs, buildTimestampFilename,
buildConcatList, buildConcatTransitionArgs,
buildHlsArgs, buildDashArgs,
buildGifArgs, buildGifPalettegenFilter, buildGifPaletteuseFilter,
buildMetadataArgs, buildChapterContent,
buildPipeThroughArgs, buildStreamOutputArgs,
buildWatermarkFilter, buildTextWatermarkFilter,
buildBurnSubtitlesFilter, buildBurnTimecodeFilter,
buildWaveformFilter, buildSpectrumFilter,
buildLoudnormFilter, buildSilenceDetectFilter,
buildSceneSelectFilter,
} from 'mediaforge';
// Screenshot
const args = buildScreenshotArgs('input.mp4', 'thumb.jpg', 3, '320x180');
// → ['-y','-ss','3','-i','input.mp4','-vframes','1','-s','320x180','thumb.jpg']
// Pipe a frame as raw bytes (e.g. for on-the-fly image processing)
const args = buildFrameBufferArgs('input.mp4', 5, 'png');
// → ['-y','-ss','5','-i','input.mp4','-vframes','1','-f','image2pipe','-vcodec','png','pipe:1']
// GIF two-pass (palettegen + paletteuse)
const { pass1, pass2 } = buildGifArgs('input.mp4', 'palette.png', 'out.gif', 10, 320, 'bayer');
// Loudnorm filter string
const filter = buildLoudnormFilter(-23, 7, -2);
// → 'loudnorm=i=-23:lra=7:tp=-2' (lowercase keys)
// Two-pass loudnorm: pass the parseLoudnorm() result straight in.
const stats = await parseLoudnorm({ input: 'audio.wav' });
const measured = buildLoudnormFilter(-14, 11, -1.5, stats);
// → 'loudnorm=…:measured_i=…:measured_lra=…:measured_tp=…:measured_thresh=…:linear=true'
// `targetOffset` is optional; when absent the `offset=` option is omitted
// entirely (emitting `offset=undefined` makes ffmpeg abort). The snake_case
// spelling ffmpeg prints (input_i, input_lra, input_tp, input_thresh) is also
// accepted.
// Scene select filter string
const filter = buildSceneSelectFilter(0.4);
// → "select='gt(scene,0.4)',metadata=print"
// (showinfo does not print the scene score; metadata=print emits
// lavfi.scene_score for each selected frame, which is what detectScenes reads)
// Silence detect filter string
const filter = buildSilenceDetectFilter(-40, 1.0);
// → 'silencedetect=noise=-40dB:d=1'
// FFMETADATA chapter file content
const meta = buildChapterContent([
{ title: 'Intro', startSec: 0, endSec: 30 },
{ title: 'Main', startSec: 30, endSec: 180 },
]);
// Expand a frame-numbered filename pattern
buildTimestampFilename('frame_%04d.jpg', 6, 'jpg'); // → 'frame_0006.jpg'
buildTimestampFilename('frame_%03d.png', 6, 'png'); // → 'frame_006.png'
buildTimestampFilename('frame_%d.png', 6, 'png'); // → 'frame_0006.png'
// The index is 0-based, as ffmpeg's own `%03d` output is, and is padded to the
// width the placeholder declares (a bare `%d` falls back to four digits). `ext`
// may be written with or without its dot and the pattern may or may not already
// carry it, so `frame_%04d` + `png` and `frame_%04d.png` + `png` both give
// `frame_0006.png`.
//
// A pattern with no `%d` placeholder still gets the index, inserted before the
// extension and zero-padded to four digits:
buildTimestampFilename('clip.jpg', 9, 'jpg'); // → 'clip0009.jpg'
// Without that, every extracted frame would be written to the same name.import {
mapStream, mapAll, mapAllVideo, mapAllAudio, mapAllSubtitles,
mapVideo, mapAudio, mapSubtitle, mapLabel, mapAVS,
negateMap, setStreamMetadata, setMetadata, setDisposition,
streamCodec, copyStream, remuxAll, mapDefaultStreams, copyAudioAndSubs,
serializeSpecifier, ss,
} from 'mediaforge';
// Map all streams from input 0
await ffmpeg('input.mkv').output('out.mkv').addOutputOption(...mapAll(0)).run();
// Map specific stream types
await ffmpeg('input.mp4').output('out.mp4')
.map(mapVideo(0, 0)[1]) // first video stream
.map(mapAudio(0, 1)[1]) // second audio stream
.videoCodec('copy').audioCodec('copy').run();
// Negate a mapping (exclude subtitle streams)
await ffmpeg('input.mkv').output('out.mp4')
.map('0').addOutputOption(...negateMap('0:s'))
.videoCodec('copy').audioCodec('copy').run();
// Remux all streams (copy everything)
await ffmpeg('input.mkv').output('out.mp4')
.addOutputOption(...remuxAll()).run();
// Set stream metadata
await ffmpeg('in.mp4').output('out.mp4')
.addOutputOption(...setStreamMetadata(0, 'a', 0, 'language', 'eng'))
.addOutputOption(...setDisposition(0, 'a', 0, ['default']))
.videoCodec('copy').audioCodec('copy').run();
// Map AVS (all three types at once). Subtitle pads are optional ('?') so a file
// without subtitles still works.
const mapping = mapAVS(0); // returns ['-map','0:v','-map','0:a','-map','0:s?']
// Stream specifier object → serialized form.
// ss(fileIndex, type?, streamIndex?, negate?) — the third argument is a numeric
// stream index, NOT a language tag. Serializing it yields '0:a:eng', which is
// not a valid ffmpeg specifier. To select by language, probe first and use
// findStreamByLanguage() to get the real index.
const v = ss(0, 'v', 0); // → '0:v:0'
const a = ss(1, 'a'); // → '1:a'
const notSubs = ss(0, 's', 0, true); // → '-0:s:0'
// mapStream() has two forms, and both return the same ['-map', spec] tuple:
mapStream('0:a:1'); // → ['-map', '0:a:1'] (spec/string form)
mapStream(0, 'v', 0); // → ['-map', '0:v:0'] (numeric form)import { ffmpeg } from 'mediaforge';
import { nvencToArgs, vaapiToArgs } from 'mediaforge';
// NVENC (NVIDIA)
await ffmpeg('input.mp4')
.hwAccel('cuda')
.output('output.mp4')
.addOutputOption(...nvencToArgs({ preset: 'p4', cq: 23 }, 'h264_nvenc'))
.run();
// VAAPI (Intel/AMD on Linux)
await ffmpeg('input.mp4')
.hwAccel('vaapi', { device: '/dev/dri/renderD128' })
.output('output.mp4')
.addOutputOption(...vaapiToArgs({}, 'h264_vaapi'))
.run();
// Auto-select best available hardware
const builder = new FFmpegBuilder('input.mp4');
const bestHw = builder.selectHwaccel(['cuda', 'vaapi', 'videotoolbox']);
if (bestHw) builder.hwAccel(bestHw);For advanced complex filter graphs you can build nodes and links directly:
import {
filterGraph, videoFilterChain, audioFilterChain,
VideoFilterChain, AudioFilterChain, FilterChain, FilterGraph,
GraphNode, GraphStream, serializeNode, serializeLink, pad, resetLabelCounter,
} from 'mediaforge';
// Fluent video filter chain — videoFilterChain() takes NO arguments.
// Build it with its methods (all positional), then serialize:
const chain = videoFilterChain()
.scale(1280, 720)
.unsharp(5, 5, 1.0);
// chain.toString() → 'scale=1280:720,unsharp=lx=5:ly=5:la=1'
ffmpeg('in.mp4').output('out.mp4').videoFilter(chain.toString()).run();
// Same idea for audio
const af = audioFilterChain()
.loudnorm(-23, 7, -2)
.highpass(80);
// af.toString() → 'loudnorm=i=-23:lra=7:tp=-2,highpass=f=80'
// Filter graph — connect multiple streams with labels
const fg = filterGraph();
// Use pad() to create named stream labels
const inPad = pad('0:v'); // toString() → '[0:v]'
const outPad = pad('vout'); // toString() → '[vout]'
// Serialize a graph node
const node = serializeNode({ name: 'scale', positional: [640, 360], named: {} });
// → 'scale=640:360'
// Serialize a filter link → '[0:v]scale=640:360[vout]'
const link = serializeLink({ inputs: [inPad], filter: { name: 'scale', positional: [640, 360], named: {} }, outputs: [outPad] });
// resetLabelCounter() is a DEPRECATED NO-OP kept for backwards compatibility.
// Label counters are per-FilterGraph instance, so there is nothing global to
// reset; it returns undefined.Video and audio filter functions are not uniform — only some support both calling styles:
| Style | Video filters | Audio filters |
|---|---|---|
| Dual | crop, curves, delogo, drawbox, drawgrid, drawtext, fade, overlay, scale, vaguedenoiser, vignette |
atempo, bass, equalizer, headphones, loudnorm, sofalizer, treble, volume |
| Chained only | avgblurVulkan, boxblur, chromakey, colorSource, colorbalance, colorkey, concat, deband, deflicker, deshake, eq, format, fps, gblur, hflip, hqdn3d, hstack, hue, nlmeans, nlmeansVulkan, pad, rotate, select, setdar, setpts, setsar, smartblur, split, subtitles, thumbnail, tile, transpose, trim, unsharp, vflip, vstack, xstack, yadif, zoompan |
aecho, afade, agate, amerge, amix, aresample, asetpts, asplit, atrim, channelmap, channelsplit, compand, dynaudnorm, highpass, lowpass, pan, rubberband, silencedetect |
Dual filters take options directly (standalone) or a FilterChain first:
import { scale, crop, loudnorm, bass } from 'mediaforge';
// Standalone — returns a filter string
scale({ w: 320, h: 180 }); // 'scale=320:180'
crop({ w: 100, h: 50 }); // 'crop=100:50'
loudnorm({ i: -16, lra: 11 }); // 'loudnorm=i=-16:lra=11' (no tp unless given)
bass({ gain: 4 }); // 'bass=g=4'
// Chained — same options, but prepended with a FilterChain
const chain = new FilterChain();
scale(chain, { w: 1280, h: 720 });Chained-only filters REQUIRE a FilterChain as the first argument and
mutate it, returning the chain:
import { FilterChain, unsharp, eq, hflip, subtitles, highpass, pan } from 'mediaforge';
const chain = new FilterChain();
unsharp(chain, 5, 5, 1.0);
eq(chain, 0.1, 1.1, 1.0);
hflip(chain);
chain.toString(); // 'unsharp=lx=5:ly=5:la=1,eq=brightness=0.1:...,hflip'
// Calling one without a chain throws
// unsharp(5, 5, 1.0) → TypeErrorScaleOptions and CropOptions accept w/h shorthand for width/height
(both forms produce the same output).
import { scale, loudnorm } from 'mediaforge';
import { filterGraph } from 'mediaforge';
// Simple video filter
await ffmpeg('input.mp4')
.output('output.mp4')
.videoFilter(scale({ w: 1280, h: 720 }))
.run();
// Audio filter (video and audio filters can be combined in one call each)
await ffmpeg('input.mp4')
.output('output.mp4')
.audioFilter(loudnorm({ i: -16, lra: 11, tp: -1.5 }))
.run();
// Complex filter graph — use .complexFilter() for raw -filter_complex strings
await ffmpeg('input.mp4')
.complexFilter('[0:v]scale=1280:720[v];[0:a]volume=0.5[a]')
.output('output.mp4')
.map('[v]').map('[a]')
.run();77 built-in filters (51 video + 26 audio).
Video (51): avgblurVulkan, boxblur, chromakey, colorSource, colorbalance, colorkey, concat, crop, curves, deband, deflicker, delogo, deshake, drawbox, drawgrid, drawtext, eq, fade, format, fps, gblur, hflip, hqdn3d, hstack, hue, levels (alias of colorlevels), nlmeans, nlmeansVulkan, overlay, videoPad (the video pad filter), rotate, scale, select, setdar, setpts, setsar, smartblur, split, subtitles, thumbnail, tile, transpose, trim, unsharp, vaguedenoiser, vflip, vignette, vstack, xstack, yadif, zoompan
Audio (26): aecho, afade, agate, amerge, amix, aresample, asetpts, asplit, atempo, atrim, bass, channelmap, channelsplit, compand, dynaudnorm, equalizer, headphones, highpass, loudnorm, lowpass, pan, rubberband, silencedetect, sofalizer, treble, volume
import {
probe, probeAsync, ProbeError,
getVideoStreams, getAudioStreams,
getDefaultVideoStream, getDefaultAudioStream,
getMediaDuration, durationToMicroseconds,
summarizeVideoStream, summarizeAudioStream,
parseFrameRate, parseDuration, parseBitrate,
isHdr, isInterlaced, getChapterList,
findStreamByLanguage, formatDuration,
getSubtitleStreams, getStreamLanguage,
} from 'mediaforge';
// Synchronous probe
const info = probe('video.mp4');
console.log(info.format?.duration); // "120.042000"
console.log(info.streams[0]?.codec_name); // "h264"
// Async probe
const info = await probeAsync('video.mp4');
// Helpers
const videoStreams = getVideoStreams(info);
const audioStreams = getAudioStreams(info);
const duration = getMediaDuration(info); // seconds
const us = durationToMicroseconds(duration!); // microseconds
const videoSummary = summarizeVideoStream(getDefaultVideoStream(info)!);
// { codec: 'h264', width: 1920, height: 1080, fps: 30, bitrate: 4000000, ... }
console.log(isHdr(info)); // true/false
console.log(isInterlaced(info)); // true/false
console.log(getChapterList(info)); // [{ title, startSec, endSec }]
const engAudio = findStreamByLanguage(info, 'eng', 'audio');Parser helpers
| Helper | Accepts | Notes |
|---|---|---|
parseDuration(s) |
'120.042' and '00:02:00.042' / '2:00' |
Handles both ffprobe JSON seconds and ffprobe text clock notation. null for '', 'N/A', junk. |
parseFrameRate(s) |
'30000/1001', '25/1' |
null for '0/0', 'N/A', junk, and negative numerators. |
parseBitrate(s) |
'4200000' |
ffprobe's numeric bit_rate. Decimal suffixes like '1.5M' are not ffprobe's format and truncate. |
formatDuration(sec) |
a finite, non-negative number | Returns HH:MM:SS.mmm. Throws RangeError for negative, NaN, or Infinity instead of emitting "-1:-1:-5.000". |
import { renice, autoKillOnExit, killAllFFmpeg } from 'mediaforge';
// Lower priority of running encode (Linux/macOS: -20 to 19, Windows: maps to priority class)
const proc = ffmpeg('input.mp4').output('out.mp4').spawn();
renice(proc.child, 10); // lower priority — works on Linux, macOS, and Windows
// Auto-kill on process exit (prevents orphan ffmpeg processes)
// Listens to exit, SIGINT, SIGTERM — does NOT touch uncaughtException
// Re-raises the signal after cleanup so Node.js retains default exit behavior
const unregister = autoKillOnExit(proc.child);
proc.emitter.on('end', () => unregister());
// Emergency: kill all ffmpeg processes on this machine (Linux/macOS/Windows)
killAllFFmpeg('SIGTERM');import { ffmpeg } from 'mediaforge';
const proc = ffmpeg('input.mp4')
.output('output.mp4')
.videoCodec('libx264')
.enableProgress()
.spawn({ parseProgress: true });
proc.emitter.on('start', (args) => console.log('Started:', args));
proc.emitter.on('progress', (info) => {
console.log(`${info.percent?.toFixed(1)}% — fps: ${info.fps} — speed: ${info.speed}x`);
});
proc.emitter.on('stderr', (line) => { /* raw stderr line */ });
proc.emitter.on('end', () => console.log('Done'));
proc.emitter.on('error', (err) => {
// err is FFmpegSpawnError — check err.stderrOutput for ffmpeg diagnostics
console.error(err.stderrOutput);
});
await new Promise((res, rej) => {
proc.emitter.on('end', res);
proc.emitter.on('error', rej);
});// Deno — import from JSR
import { ffmpeg, probe, screenshots } from "jsr:@globaltech/mediaforge";
// Transcode (requires --allow-env --allow-run=ffmpeg,ffprobe --allow-read --allow-write)
await ffmpeg("input.mp4")
.output("output.mp4")
.videoCodec("libx264")
.audioBitrate("128k")
.run();
// Probe a file
const info = probe("video.mp4");
console.log(info.format?.duration);
// Screenshots
const { files } = await screenshots({ input: "video.mp4", folder: "./thumbs", count: 5 });Deno permissions required:
# --allow-env is needed to read FFMPEG_PATH / FFPROBE_PATH
deno run --allow-env --allow-run=ffmpeg,ffprobe --allow-read --allow-write your-script.ts// Bun — same API as Node.js
import { ffmpeg } from "mediaforge";
await ffmpeg("input.mp4")
.output("output.mp4")
.videoCodec("libx264")
.run();Every feature in this library is exercised on Node, Deno and Bun by a single
shared suite, runtime-tests/battle.ts, which imports lib/index.ts directly
(so there is no build step and no dist/ to go stale):
npm run battle:runtime # Node (tsx)
deno task battle:runtime # Deno
bun run runtime-tests/battle.ts # Bun
npm run battle:runtimes # all three in sequenceIt runs the same real-ffmpeg checks on each runtime: quality metrics, tone
mapping, frame interpolation, scene cutting, silence removal, segmenting,
subtitle conversion, ABR ladders, delogo, the hardware filter builders, the
encode controls, and the whole CLI task table. The CLI end-to-end section
(DRIVING THE BINARY) is skipped on Deno, which imports the TypeScript source and
has no dist/ to execute; Node and Bun run it in full.
Because the suite is deliberately runtime-agnostic — it reaches Deno and Bun
globals only through a small RUNTIME adapter — a regression that only appears
on one runtime is a real portability bug, not a test artefact.
# Transcode
mediaforge -i input.mp4 -c:v libx264 -b:v 2M -c:a aac output.mp4
# Probe a file
mediaforge probe video.mp4
# List capabilities
mediaforge caps --codecs
mediaforge caps --filters
mediaforge caps --formats
mediaforge caps --hwaccels
mediaforge caps # all of the above
# Show version
mediaforge version
# Help
mediaforge --helpSubcommands: version, probe <file>, caps, help (--help / -h).
With no arguments the CLI prints usage and exits 0.
The raw ffmpeg passthrough above is fine for one-off encodes, but it hides the helpers this library actually ships. The other half of the CLI is a set of 52 task commands that cover the whole library surface: 31 editing commands, plus the filter, graph, codec, mapping, preset, analysis, hardware and arg-printing commands that give every exported helper a CLI entry point.
mediaforge trim input.mp4 out.mp4 --start 5 --end 30
mediaforge hls input.mp4 --outdir ./hls --segment 6
mediaforge chapters input.mp4 out.mp4 --chapters "Intro:0,Main:120"
mediaforge quality encode.mp4 --reference source.mp4 --metric vmaf --min 90
mediaforge tonemap hdr.mp4 sdr.mp4 --algorithm hableRun mediaforge help for the list, or mediaforge <task> --help for one task.
A misspelled flag is a hard error, not a silent default.
| Task | What it does |
|---|---|
trim <in> <out> |
Cut a range (--start / --end / --duration) |
speed <in> <out> |
Change playback speed, pitch-corrected audio (--factor) |
volume <in> <out> |
Scale volume (--gain) |
normalize <in> <out> |
Two-pass EBU R128 loudness (--target LUFS) |
extract <in> <out> |
Pull out the audio track |
replace-audio <video> <audio> <out> |
Swap a video's audio |
concat <out> --inputs a,b |
Concatenate, stream-copying when possible (--reencode to force) |
transitions <out> --inputs a,b |
Join with xfade transitions (--transition, --duration) |
hls <in> --outdir d |
Single-bitrate HLS (--segment, --bitrate, --hls-version) |
abr <in> --out v%v/i.m3u8 --variants … |
Multi-bitrate ladder (--variants name=WxH:bitrate,…) |
dash <in> <out.mpd> |
DASH manifest (--segment, --bitrate) |
segments <in> --pattern "seg%03d.ts" |
Fixed-length segments (--segment) |
chapters <in> <out> --chapters … |
Mux chapter markers (Intro:0,Main:120) |
metadata <in> <out> --set k=v |
Write tags, or --strip to remove them |
thumbnail <in> <out> |
One frame to an image (--at, --size, --format) |
sprite <in> <out.png> |
Thumbnail sprite sheet (--columns, --count, --width) |
frames <in> --outdir d |
Frame sequence (--fps, --format) |
gif <in> <out.gif> |
Animated GIF (--fps, --width, --colors) |
watermark <in> <logo> <out> |
Overlay a watermark (--position, --opacity, --margin) |
text <in> <out> --text "…" |
Burn in text (--position, --size, --color, --font) |
subtitles <in> <out> |
Burn (--file) or convert (--convert, --shift, --fix-duration) |
quality <ref> <dist> |
VMAF / SSIM / PSNR, with --min as a CI gate |
tonemap <in> <out> |
HDR → SDR (--algorithm, --peak, --desat, --force) |
interpolate <in> <out> --fps 60 |
Motion-compensated frame interpolation (--method mci|blend|dup) |
silence <in> <out> |
Cut silence (--threshold, --min, --video) or --detect to report it |
scenes <in> [out] |
Report scene changes, or --cut to auto-edit on them |
waveform <in> <out.png> |
Waveform image (--width, --height, --color, --scale) |
spectrum <in> <out> |
Spectrum video (--palette, --width, --height) |
delogo <in> <out> |
Blur a region (--x, --y, --width, --height) |
twopass <in> <out> --bitrate 2M |
Two-pass bitrate-targeted encode |
to-bitrate <in> <out> --bitrate 2M |
Re-encode to a target bitrate (--crf, --preset, --maxrate, --bufsize) |
filter <name> [k=v…] <in> <out> |
Any of the 77 built-in filters by name (--list, --print, --chain, --audio) |
graph <in> <out> --pipeline … |
Build a -filter_complex from a JSON step list (--print, --map) |
codec <name> [k=v…] |
Print the encoder args a codec builder produces (--list) |
map <in> <out> |
Build -map args from the stream DSL (--all, --remux, --default, --av, --print, …) |
preset [name] <in> <out> |
List, inspect or apply a named encode preset (--list, --print, --size, --crf) |
analyze <file> |
Structured media report: streams, chapters, HDR, interlacing (--json) |
features |
ffmpeg feature gates for the installed binary (--ffmpeg-version, --missing) |
hwaccel <name> <in> <out> |
GPU upload/scale/download transcode (--list, --check, --print, --width, --height) |
args <op> [k=v…] |
Print the exact argv any arg builder produces (--list) |
stack <out> <in…> |
hstack / vstack several clips (--direction, --shortest) |
mix <out> <in…> |
Mix audio tracks (--weights, --duration, --bitrate, --codec) |
loop <in> <out> |
Repeat a clip (--times, --duration, --codec) |
deinterlace <in> <out> |
yadif deinterlacing (--mode, --parity, --deint) |
stabilize <in> <out> |
vidstab stabilisation (--smoothing, --max-shift, --max-angle, --crop) |
aspect <in> <out> --ratio 1:1 |
Center-crop to an aspect ratio (--codec) |
lut <in> <out> --lut grade.cube |
Apply a .cube / .3dl table (--interp, --codec) |
timecode <in> <out> |
Burn a timecode counter (--position, --fontsize, --fontcolor, --format) |
cropdetect <in> |
Report the real content region (--limit, --skip) |
gif2mp4 <in.gif> <out.mp4> |
Convert a GIF to MP4 (--width) |
extract-subs <in> <out.srt> |
Pull a subtitle stream to a file (--stream) |
retime-subs <in> <out.srt> |
Retime a subtitle file to the video (--format, --stream) |
filter — every built-in filter by name. The registry covers all 77 filters
(51 video, 26 audio), so the CLI never needs a bespoke command per filter:
mediaforge filter --list # every name with its option keys
mediaforge filter scale w=160 h=90 in.mp4 out.mp4
mediaforge filter eq contrast=1.1 --print in.mp4 # print the chain, encode nothing
mediaforge filter --chain 'scale:w=160,h=90|eq:contrast=1.1' in.mp4 out.mp4
mediaforge filter volume volume=0.5 in.mp4 out.m4a --audiograph, codec and args — inspect before you encode. Each prints the argv
the corresponding library helper builds, so a complex command can be checked,
copied and pasted into a script:
mediaforge graph in.mp4 out.mp4 --print \
--pipeline '[{"from":"0:v","filter":"scale","args":["1280","720"]}]'
mediaforge codec x264 preset=medium crf=20
mediaforge codec --list
mediaforge args two-pass input=in.mp4 output=out.mp4 videoBitrate=2M
mediaforge args --listmap — the stream-mapping DSL, end to end. Every selector the DSL supports is
a flag, and --print shows the args without remuxing:
mediaforge map in.mkv out.mp4 --default --print
mediaforge map in.mkv out.mp4 --av --exclude 0:a:2 --print
mediaforge map in.mkv out.mkv --remuxanalyze and features — what this ffmpeg can actually do. analyze prints a
structured report (streams, default streams, chapters, HDR, interlacing) and
--json emits it compactly. features evaluates the version gates in
FEATURE_GATES against the installed binary, or against a version you name with
--ffmpeg-version 7.0 — useful for checking a build before a release job runs:
mediaforge analyze in.mp4 --json
mediaforge features --missing
mediaforge features --ffmpeg-version 7.0Library-only exports. A handful of public exports are deliberately not task
commands, because they are process lifecycle or internals rather than a user
operation: autoKillOnExit, killAllFFmpeg, renice, captureStderr, isDeno,
spawnFFmpeg and runFFmpeg. They are listed, with the reason, in LIBRARY_ONLY,
and the CLI/library parity test fails if a new export appears that is neither in a
command nor on that list — so a missing command is always a decision rather than an
oversight.
mediaforge-specific flags:
| Flag | Description |
|---|---|
--ffmpeg <path> |
Path to the ffmpeg binary (default: FFMPEG_PATH env or ffmpeg) |
--ffprobe <path> |
Path to the ffprobe binary (default: FFPROBE_PATH env or ffprobe) |
--hwaccel <name> |
Hardware acceleration (cuda, vaapi, mediacodec, vulkan, qsv) |
--hwaccel-device <path> |
Device path, e.g. /dev/dri/renderD128 for VAAPI |
--progress |
Enable progress reporting on stderr |
--loglevel <level> |
quiet|panic|fatal|error|warning|info|verbose|debug|trace |
-y / -n |
Overwrite / never overwrite (the CLI injects -y unless -n is present) |
--codecs, --filters, --formats, --hwaccels |
Filters for the caps subcommand |
Everything else is forwarded to ffmpeg verbatim, so standard ffmpeg flags work:
-i, -ss, -t, -to, -f, -c:v, -c:a, -c:s, -b:v, -b:a, -ar, -ac,
-r, -s, -crf, -preset, -pix_fmt, -vf, -af, -vn, -an, -sn,
-map, -filter_complex, and so on.
# Hardware encode with progress
mediaforge --hwaccel cuda --hwaccel-device /dev/dri/renderD128 \
-i input.mp4 -c:v h264_vaapi ./out.mp4 --progress
# Extract audio
mediaforge -i input.mp4 -vn -c:a libopus -b:a 128k output.opus
# Explicit binaries
mediaforge --ffmpeg /opt/ffmpeg/bin/ffmpeg --ffprobe /opt/ffmpeg/bin/ffprobe versionimport {
guardCodec, guardHwaccel, guardFeatureVersion,
selectBestCodec, selectBestHwaccel, assertCodec,
CapabilityRegistry, getDefaultRegistry,
} from 'mediaforge';
// Check codec / hwaccel availability at runtime
guardCodec(registry, 'libx264', 'encode'); // → { available: boolean, reason?: string }
guardHwaccel(registry, 'cuda'); // → { available: boolean }
assertCodec(registry, 'libx264', 'encode'); // throws GuardError if unavailable
// Auto-select best available codec from priority list
selectBestCodec(version, registry, [
{ codec: 'h264_nvenc', featureKey: 'nvenc' },
{ codec: 'h264_vaapi' },
{ codec: 'libx264' }, // software fallback
]);
// Auto-select best hardware accelerator
selectBestHwaccel(registry, ['cuda', 'vaapi', 'videotoolbox']);
// Access the runtime capability registry directly
const registry = getDefaultRegistry('ffmpeg'); // or pass explicit path, e.g. '/usr/local/bin/ffmpeg'
const custom = new CapabilityRegistry('ffmpeg'); // custom binary path
console.log(registry.hasCodec('libx264')); // true
console.log(registry.canEncode('libx264')); // true
console.log(registry.canEncode('aac')); // true
console.log(registry.hasFilter('scale')); // true
console.log(registry.hasFormat('mp4')); // true
// `encoders` is the full `ffmpeg -encoders` list (200+ on a stock build), not
// just the subset `ffmpeg -codecs` names in a parenthesised list.
console.log(registry.encoders.size); // 200+ encoders on standard FFmpeg buildsimport { FFmpegBuilder, selectBestCodec } from 'mediaforge';
// Builders expose selectHwaccel() to pick the best available hardware
const builder = new FFmpegBuilder('input.mp4');
const hwaccel = builder.selectHwaccel(['cuda', 'vaapi', 'videotoolbox']);
console.log(hwaccel); // 'cuda' | 'vaapi' | null (if none are available)
// For codec selection use the standalone selectBestCodec(), or the builder's
// selectVideoCodec() wrapper which reuses the builder's cached version/registry.
const codec = selectBestCodec(builder.getVersion(), registry, [
{ codec: 'h264_nvenc', featureKey: 'nvenc' },
{ codec: 'h264_vaapi' },
{ codec: 'libx264' }, // software fallback
]);
const picked = builder.selectVideoCodec([
{ codec: 'h264_nvenc', featureKey: 'nvenc' },
{ codec: 'libx264' },
]);| FFmpeg Version | Support |
|---|---|
| v8.x | ✅ Full — unlocks MediaCodec, Vulkan encode, AMF, VideoToolbox, AV1, Dolby Vision |
| v7.x | ✅ Full — unlocks AV1 (nvenc/vaapi/qsv), 10-bit x264/x265/vp9/SVT-AV1 |
| v6.x | ✅ Full baseline — every feature gate in FEATURE_GATES is minMajor: 6 |
| v5.x and below | ❌ Not supported — below every feature gate |
mediaforge's
levels()helper serializes to FFmpeg'scolorlevelsfilter (present in v6/v7), not the unrelatedlevelsfilter introduced in FFmpeg 7.x. See Color Grading & Visual Filters.
Tested with Node.js 20, 22, 24.
| Variable | Default | Description |
|---|---|---|
FFMPEG_PATH |
ffmpeg |
Path to ffmpeg binary |
FFPROBE_PATH |
ffprobe |
Path to ffprobe binary |
Contributions, issues and feature requests are welcome! Feel free to open an issue or submit a pull request.
Distributed under the MIT License. See LICENSE for more information.
