@magnaboy/cli-ffmpeg
v0.0.2
Published
ffprobe parsing, filter-graph building and atomic batched ffmpeg encodes for Node.js CLIs.
Readme
@magnaboy/cli-ffmpeg
ffmpeg from a script instead of from bash: typed ffprobe output, filter graphs built rather than string-concatenated, encodes that leave no partial files, and a batch that does not melt the machine.
Install
npm i @magnaboy/cli-ffmpegRequires Node 25+, ESM, and ffmpeg/ffprobe on PATH. Calls take a ProcessRunner from
@magnaboy/cli-core.
import { probeMedia, requireFfmpegTools, runFfmpeg } from '@magnaboy/cli-ffmpeg';Find the tools once
const { ffmpeg, ffprobe } = await requireFfmpegTools(runner);Both are resolved together and every missing one is named in a single failure, so a script that probes before it encodes stops at the start rather than after the first ten minutes of work.
Probing
const info = await probeMedia(runner, ffprobe, 'clip.mp4');
info.durationSeconds; // number | null
hasAudio(info);
firstVideoStream(info)?.frameRate; // 29.97 from "30000/1001"Every numeric field is number | null, never NaN. ffprobe reports N/A for a duration it cannot
determine and 0/0 for an unknown frame rate, and turning either into a number silently produces a
zero-length encode.
longestDuration takes the maximum across inputs for a montage that must outlast every clip. It
skips inputs whose duration is unknown rather than counting them as zero, and returns null rather
than 0 for an empty set, so a caller cannot silently encode nothing.
Filter graphs
import { buildAudioMix, buildGridFilter } from '@magnaboy/cli-ffmpeg/filters';
const grid = buildGridFilter({
count: files.length,
tile: { width: 960, height: 540 },
fillerDurationSeconds: duration,
limit: { width: 3840, height: 2160 }
});
const mix = buildAudioMix({ inputs: indexesWithAudio });
await runFfmpeg(runner, {
ffmpeg,
inputs: files.map(file => ({ file, loop: true })),
filterComplex: [grid.filter, mix?.filter].filter(Boolean).join(';'),
maps: [grid.videoLabel, ...(mix ? [mix.audioLabel] : [])],
outputOptions: ['-c:v', 'h264_nvenc', '-preset', 'p1', '-cq', '25'],
durationSeconds: duration,
output: 'grid.mp4'
});gridShape picks the squarest grid that holds the count. Every input is reset to a zero PTS, since
inputs starting at different timestamps otherwise stack with a visible offset. Unused slots are
filled with black because hstack needs exactly columns inputs per row — and a partial grid
without fillerDurationSeconds is refused rather than emitted as a graph that fails deep inside
ffmpeg.
scaleAndCrop fills the tile, scaleAndPad keeps the whole frame, limitSize caps without
enlarging.
Encoding
await encodeAtomically(runner, { ffmpeg, inputs: [source], outputOptions, output });ffmpegSpec assembles arguments in the order ffmpeg requires: an input option placed after its
-i applies to nothing, and a -map before the filter graph names a pad that does not exist yet.
Filenames are arguments, never shell text, so a clip called -i.mp4 is still a file.
Overwriting is off by default (-n), so a rerun cannot silently destroy an earlier render.
encodeAtomically writes to output.part and renames only on a clean exit. ffmpeg leaves a partial
file behind when interrupted, and a partial file is indistinguishable from a finished one on the
next run; renaming last is what makes "the output exists" mean "the output is complete", which is
what lets a batch resume by skipping what is already there.
Batches
const failures = await runBatch({ items: clips, maxConcurrency: 4, signal }, clip =>
encodeAtomically(runner, { ffmpeg, inputs: [clip.source], output: clip.target })
);
if (failures.length > 0) throw new Error(`${failures.length} clips failed`);ffmpeg saturates a machine on its own, so an unbounded Promise.all over a directory is slower than
a bounded queue as well as being unkillable. Failures are collected rather than thrown, so one bad
input does not discard the work already finished, and an aborted signal stops new work without
killing what is in flight.
