mediaforge - v2.2.0
    Preparing search index...

    mediaforge - v2.2.0

    mediaforge

    NPM JSR codecov Downloads

    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 mediaforge

    cli

    npm install -g mediaforge
    

    deno

    deno add jsr:@globaltech/mediaforge
    

    Deno (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_process and node:events internally. You must grant --allow-env (for FFMPEG_PATH/FFPROBE_PATH resolution) and --allow-run (for spawning the ffmpeg/ffprobe binaries) when running your script:

    deno run --allow-env --allow-run my-script.ts
    

    The CLI reports the exact grant it needs rather than an opaque failure.

    No runtime dependencies. package.json declares no dependencies at all — mediaforge is built entirely on the Node.js standard library and drives the system ffmpeg as a subprocess. npm audit --omit=dev reports zero vulnerabilities for anything a consumer installs.



    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 / -af pair, 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:

    1. Output: -movflags frag_keyframe+empty_moov+default_base_moof is injected automatically for mp4/mov output formats so the stream is seekable-free. Pass your own -movflags to override.
    2. Input: -analyzeduration 100M -probesize 100M is injected automatically when inputFormat is mp4/mov/m4v, giving FFmpeg enough buffer to locate the moov atom 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, LUFS

    import { 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,
    });

    generateSpectrum colour is a palette, not a CSS colour. Unlike generateWaveform (whose colour is a CSS colour for showwavespic), the showspectrum filter's color option is an integer enum of named palettes. Passing 'red' or '#ff0000' makes ffmpeg abort with Undefined constant or missing '(' in 'red'. The valid values are exported as SPECTRUM_COLORS and typed as SpectrumColor: channel, intensity, rainbow, moreland, nebulae, fire, fiery, fruit, cool, magma, green, viridis, plasma, cividis, terrain. Note that green is a valid palette name while red is not — an easy trap.

    FFmpeg 7.x compatibility: the showwavespic filter's bgcolor and draw parameters were removed in FFmpeg 7.1, so generateWaveform never emits them. The backgroundColor and mode options 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. The videoFilter-style builder buildWaveformFilter() takes the colour verbatim (no # stripping), and its streamIndex defaults 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();

    mp3ToArgs and opusToArgs are also exported under the aliases libMp3LameToArgs and libOpusToArgs.

    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.0
    import { 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 file
    import { 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 as colorlevels(), and it serializes to FFmpeg's colorlevels filter — not the unrelated levels filter added in FFmpeg 7.x. colorlevels is 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.x levels filter.

    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 } ← camelCase
    import { 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 FilterChain throws TypeError: Cannot read properties of undefined. For example highpass({ frequency: 80 }) is not valid — use highpass(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 / -vf pair 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. t is transfer, p primaries, m matrix, r range — and w/h are pixel size. width=bt2020 would be read as a pixel size and rejected with Invalid 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" codec

    A 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_* and dash_* flags are output-private options and must appear after -f hls / -f dash in the argument list, or FFmpeg rejects them with Unrecognized option. FFmpegBuilder.buildArgs() emits the output format before any extra output options, so ordering is handled for you. hlsFlags / dashFlags are 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) → TypeError

    ScaleOptions 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".

    Every run is bounded by default. A cancelled, timed-out or failed encode is killed — and, on POSIX, killed as a whole process group so nothing it started survives it.

    import { ffmpeg, FFmpegError, FFmpegAbortError, FFmpegTimeoutError } from 'mediaforge';

    // Cancel an encode when the request that triggered it is aborted.
    const controller = new AbortController();
    req.on('close', () => controller.abort());

    try {
    await ffmpeg('input.mp4').output('out.mp4').run({
    signal: controller.signal,
    timeout: 30 * 60_000,
    });
    } catch (err) {
    if (err instanceof FFmpegAbortError) console.log('cancelled by the caller');
    else if (err instanceof FFmpegTimeoutError) console.log(`gave up after ${err.timeoutMs}ms`);
    else throw err;
    }

    SIGTERM alone is not enough — a child that traps or ignores the signal would survive the cancel — so the kill escalates to SIGKILL after killGracePeriodMs (default 2000):

    await ffmpeg('input.mp4').output('out.mp4').run({
    signal: controller.signal,
    killGracePeriodMs: 5000, // give in-flight writes longer to settle
    });

    An already-aborted signal throws before a process is created, so a cancelled request never leaves a stray ffmpeg behind:

    const controller = new AbortController();
    controller.abort();
    await ffmpeg('input.mp4').output('out.mp4').run({ signal: controller.signal });
    // throws FFmpegAbortError; `ffmpeg` was never spawned

    inputPaths() and outputPaths() return what the builder will actually read and write, which is what you want when validating or logging a job:

    const job = ffmpeg('in.mp4').videoCodec('libx264').output('out.mp4');
    job.inputPaths(); // ['in.mp4']
    job.outputPaths(); // ['out.mp4']
    job.dry(); // ['-y', '-i', 'in.mp4', '-c:v', 'libx264', 'out.mp4']

    Every error extends FFmpegError and carries a stable code, so a service can branch without matching on message text:

    import { FFmpegError } from 'mediaforge';

    try {
    await ffmpeg('in.mp4').output('out.mp4').run();
    } catch (err) {
    if (err instanceof FFmpegError) {
    switch (err.code) {
    case 'EXIT_NONZERO': /* ffmpeg ran and failed — err.command, err.stderrOutput */
    case 'TIMEOUT': /* killed on the deadline */
    case 'ABORTED': /* cancelled via signal */
    case 'BINARY_NOT_FOUND': /* set FFMPEG_PATH */
    case 'BINARY_NOT_EXECUTABLE':
    case 'VALIDATION_FAILED': /* rejected before spawning — err.field */
    case 'ATOMIC_OUTPUT_UNSUPPORTED':
    default: throw err;
    }
    }
    }

    codes are SPAWN_FAILED, EXIT_NONZERO, TIMEOUT, ABORTED, BINARY_NOT_FOUND, BINARY_NOT_EXECUTABLE, PROBE_FAILED, VERSION_UNSUPPORTED, GUARD_FAILED, VALIDATION_FAILED, ATOMIC_OUTPUT_UNSUPPORTED.

    Every invocation of ffmpeg/ffprobe — -version, capability probes, the sync probe() — has a timeout, because an unbounded call against a wedged binary blocks the event loop and stalls every other in-flight request:

    import { probeVersion, probeVersionAsync, execBounded, execAsync } from 'mediaforge';

    const version = probeVersion('ffmpeg'); // sync, 10s default
    const versionAsync = await probeVersionAsync('ffmpeg'); // cached per binary path
    const stdout = execBounded('ffprobe', ['-version'], { timeoutMs: 2000 });
    const stdoutAsync = await execAsync('ffprobe', ['-version'], { timeoutMs: 2000 });

    Children register with one shared set of exit/SIGINT/SIGTERM handlers installed lazily and removed once the last child exits. A SIGTERM to the host therefore cannot leave ffmpeg running and still writing to the output path. Pass autoCleanup: false to opt out when you own the child's lifetime.


    One ffmpeg already saturates the machine it runs on, so spawning one per request past a handful of concurrent jobs lowers total throughput as everything competes for the same cores. FFmpegQueue caps the parallelism and queues the rest:

    import { FFmpegQueue, queued, setDefaultQueue } from 'mediaforge';

    const queue = new FFmpegQueue({ concurrency: 2 });

    await Promise.all([
    queue.run(() => transcode('a.mp4', 'a-out.mp4')),
    queue.run(() => transcode('b.mp4', 'b-out.mp4')),
    queue.run(() => transcode('c.mp4', 'c-out.mp4')),
    ]);
    // at most two ran at once; queue.stats() reports running/pending/completed/failed

    maxPending turns overload into a fast rejection instead of unbounded growth:

    const queue = new FFmpegQueue({ concurrency: 2, maxPending: 50 });
    await queue.run(job); // rejects with VALIDATION_FAILED once 50 are waiting

    queued() routes through a process-wide queue, tuned by MEDIAFORGE_CONCURRENCY:

    await queued(() => transcode(input, output));              // concurrency 1
    setDefaultQueue(new FFmpegQueue({ concurrency: 4 })); // or set your own

    An encode that fails, times out or is cancelled still leaves a file at the output path — ffmpeg opens its output before it discovers the input is unusable — and a truncated .mp4 is indistinguishable from a good one to anything downstream. withAtomicOutput writes to a temporary name and renames it into place only after ffmpeg exits 0:

    import { withAtomicOutput } from 'mediaforge';

    await withAtomicOutput('out.mp4', async (temp) => {
    await ffmpeg('in.mp4').output(temp).run();
    });
    // out.mp4 either holds the finished file or does not exist at all

    The temp file keeps the target's extension, because ffmpeg infers the container from it — writing to out.mp4.tmp would pick the wrong muxer and fail after spending the encode. The parent directory is created for you, since a rename cannot cross a filesystem boundary.

    Targets ffmpeg writes as several files (.m3u8, .mpd, frame_%03d.png) are refused with FFmpegAtomicOutputError rather than silently written non-atomically:

    await withAtomicOutput('stream.m3u8', runIt);
    // throws FFmpegAtomicOutputError: there is no single path to rename into place

    Retrying is off by default, because an ffmpeg failure is nearly always deterministic. Enable it explicitly, and pair it with atomic output so a retry never resumes from a half-written file:

    import { withAtomicOutput, withRetry } from 'mediaforge';

    await withAtomicOutput('out.mp4', (temp) =>
    ffmpeg('in.mp4').output(temp).run({
    retry: { retries: 3, initialDelayMs: 250, factor: 2, jitter: 0.2 },
    }),
    );

    Only transient causes are retried — TIMEOUT and SPAWN_FAILED. An EXIT_NONZERO (bad arguments, corrupt input) is deterministic and never retried, and neither is ABORTED, because a caller who cancelled wants the work stopped rather than resumed. Jitter is on by default so a fleet that failed together does not retry in lockstep.


    The library is silent unless asked, so it can be wired into pino/winston instead of scraping stderr:

    import { setLogger, setDiagnosticHook, stderrLogger } from 'mediaforge';

    setLogger({
    debug: (msg, meta) => log.debug(meta ?? {}, msg),
    info: (msg, meta) => log.info(meta ?? {}, msg),
    warn: (msg, meta) => log.warn(meta ?? {}, msg),
    error: (msg, meta) => log.error(meta ?? {}, msg),
    });

    // Or, for a CLI:
    setLogger(stderrLogger('info'));

    // Metrics and tracing: one event per job transition.
    setDiagnosticHook((event) => {
    if (event.type === 'end') metrics.histogram('ffmpeg.duration', event.durationMs);
    if (event.type === 'error') metrics.counter('ffmpeg.error', { code: event.code });
    });

    A logger or hook that throws can never fail the encode it is observing.


    Catch a bad path before ffmpeg starts, instead of after it has created a zero-byte output and exited non-zero:

    import { assertValidIo, validateInputs } from 'mediaforge';

    await ffmpeg('missing.mp4').output('out.mp4').run({ validate: true });
    // throws FFmpegValidationError: input file does not exist
    // → Check the path. mediaforge does not expand "~" — pass an absolute path.

    Every problem is reported at once, each with a fix. Off by default, because this library accepts ffmpeg input syntax that is not a filesystem path — URLs, pipe:0, concat:, lavfi: graphs, raw stream specifiers — and those are recognised and left alone.


    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 sequence

    It 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 --help

    Subcommands: version, probe <file>, caps, help (--help / -h). With no arguments the CLI prints usage and exits 0.

    The CLI is a usable Unix filter in both directions, and neither hangs:

    cat input.mp3 | mediaforge -i pipe:0 -f wav - > output.wav
    mediaforge -f lavfi -i testsrc=duration=1:size=640x480:rate=30 -f rawvideo pipe:1 > frames.raw

    Output-to-stdout inherits the file descriptor rather than being buffered, so a payload larger than the 64 KiB pipe buffer does not deadlock, and stdin is piped through and closed so ffmpeg sees EOF.

    case "$(mediaforge trim in.mp4 out.mp4; echo $?)" in
    0) echo "ok" ;;
    1) echo "the encode failed" ;;
    2) echo "the command line was wrong" ;;
    130) echo "interrupted" ;;
    esac
    Code Meaning
    0 success
    1 the command ran and failed (non-zero ffmpeg exit, failed quality gate)
    2 the command line was wrong — unknown command or flag, missing or surplus argument
    130 interrupted with SIGINT (Ctrl-C)
    143 terminated with SIGTERM (supervisor stop, container shutdown)

    The high codes follow the Unix convention of 128 + signal. Anything that depends on the earlier "every failure is 1" behaviour should test for 1 specifically.

    Flag arity comes from each command's flag table, so a boolean flag never consumes the next argument:

    mediaforge analyze --json input.mp3        # --json is boolean; input.mp3 stays a positional
    mediaforge metadata --no-strip in.mp4 out.mp4
    mediaforge trim -- -leading-dash.mp4 out.mp4 # after --, even dashes are literals
    mediaforge metadata in.mp4 out.mp4 --set title=A --set artist=B # repeatable

    A misspelled flag, a missing flag value, a repeated non-repeatable flag and a surplus positional are all hard errors, never silent defaults.

    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 hable

    Run 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 --audio

    graph, 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 --list

    map — 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 --remux

    analyze 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.0

    Library-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 version

    import {
    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 builds
    import { 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's colorlevels filter (present in v6/v7), not the unrelated levels filter 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.