Skip to content

ffmpeg

@alexkroman1/aai/ffmpeg — ffmpeg, callable from a step.

A FACADE. The subpath resolves here rather than at ffmpeg.ts, which buys two things the direct form could not. That module can be SPLIT as it grows without moving the published entry point — the path an implementation file happens to have is not a thing to promise anyone — and a name it gains next reaches the public surface only when a line is added below, rather than the moment it is written.

Named re-exports rather than export * for the second half of that: the wildcard form re-exports whatever arrives, and needs a noReExportAll suppression the escape-hatch ratchet only lets move down.

describeMedia(info): string

A probe as one phrase for a progress line — 41:20 of aac.

Duration and codec are both optional on a MediaInfo, and the phrase degrades a field at a time rather than printing undefined of undefined: 41:20 when ffprobe measured a length but named no codec (a raw PCM file has none), aac when the container declared no duration (a stream copy with no index, or a non-faststart MP4 probed over a pipe — see probeMedia), and the recording when it reported neither, so the sentence around it still reads.

The codec is the first AUDIO stream’s, which is what a transcription step means by “what is this file”; a video’s own codec is not the thing being re-encoded. Duration comes from the container, rounded to the second by formatDuration.

MediaInfo

string

import { describeMedia, probeMedia } from "@alexkroman1/aai/ffmpeg";
import { stepReport } from "@alexkroman1/aai/step";
const info = await probeMedia("/tmp/recording.m4a");
await stepReport(`Re-encoding ${describeMedia(info)} to 16 kHz mono WAV.`);

ffmpegBaseArgs(options?): string[]

The standing flags every ffmpeg invocation in a guest wants, before anything the caller is actually asking for.

Five spellings of this existed — four in templates, one here in transcodeToWav — and they disagreed on the two that matter:

  • -nostats is not cosmetic. A failing run is diagnosed from the stderr this package captures, and it keeps only the last FFMPEG_STDERR_TAIL_CHARS of it. ffmpeg writes a progress line several times a second, so on anything long the progress spam is what survives and the error that explains the failure is what gets evicted. Only one of the five passed it.
  • -nostdin is about the runtime, not the job. In a guest there is no terminal, and an ffmpeg that decides to read stdin is a process that never exits. That is a fact about where this SDK runs, so it belongs here rather than in each caller’s argv.

-y overwrites the output without asking, which is right for both shapes a step uses — a temp file it just named, or pipe:1.

loglevel defaults to "error". Pass "info" for a filter that reports through the LOG rather than to a file — loudnorm’s print_format=json is the case, and at error that pass runs, succeeds, and prints nothing.

ffprobe takes none of this. It rejects -nostdin and -nostats outright, so probeMedia builds its own argv and this helper is for ffmpeg only.

string

string[]

import { ffmpegBaseArgs, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
await runFfmpeg([...ffmpegBaseArgs(), "-i", "/tmp/in.m4a", "/tmp/out.wav"]);
await runFfmpeg([...ffmpegBaseArgs({ loglevel: "info" }), "-i", "/tmp/in.wav", "-f", "null", "-"]);

isFfmpegError(value): value is FfmpegError

Narrow an unknown catch to a failed ffmpeg run.

unknown

value is FfmpegError


probeMedia(source, options?): Promise<MediaInfo>

What ffprobe makes of a file: duration, container, and every stream.

import { probeMedia } from "@alexkroman1/aai/ffmpeg";
const info = await probeMedia("/tmp/recording.m4a");
const seconds = info.durationSec ?? 0;
const needsTranscode = info.audio?.codec !== "pcm_s16le";

A field ffprobe did not report comes back undefined rather than zero — see _ffmpeg-json.ts for why that distinction is load-bearing. Reading a duration off a PIPE is the one case worth knowing about: for a format whose duration lives in a trailing index, ffprobe cannot seek to it and answers undefined, where the same file on disk answers exactly.

FfmpegSource

ProbeOptions

Promise<MediaInfo>


runFfmpeg(args, options?): Promise<FfmpegRunResult>

Run ffmpeg with args, exactly as given.

Resolves only on a zero exit; every other outcome is a FfmpegError naming its FfmpegFailureKind.

readonly string[]

FfmpegRunOptions

Promise<FfmpegRunResult>

import { ffmpegBaseArgs, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
// File to file: nothing is buffered, so this is the shape for long media.
await runFfmpeg([
...ffmpegBaseArgs(),
"-i", "/tmp/in.m4a",
"-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
"/tmp/out.wav",
]);

transcodeToWav(source, options?): Promise<Uint8Array<ArrayBufferLike>>

Re-encode anything ffmpeg can read into linear-PCM WAV bytes.

The conversion a transcription pipeline needs, because cutting a recording by byte offset is only arithmetic on uncompressed audio. Video is dropped.

The result is held in memory, so it is capped like any other piped output (64 MiB) — about an hour of 16 kHz mono at the default. Past that, go file → file with wavEncodeArgs.

Note WAV written to a PIPE carries a placeholder length in its header: ffmpeg cannot seek back to patch it once the size is known. Every decoder treats it as “read to EOF”, and this repo’s own parseWav intersects the declared length with the real byte count for exactly that reason — but code that trusts the header’s data size will read zero samples.

FfmpegSource

TranscodeToWavOptions

Promise<Uint8Array<ArrayBufferLike>>


wavEncodeArgs(options?): string[]

The encoder half of a linear-PCM WAV argv — no input, no output.

Exported because the in-memory transcodeToWav is the wrong shape for a long recording, and a caller writing file → file should not have to re-derive which of ffmpeg’s codec names is uncompressed:

import { runFfmpeg, wavEncodeArgs } from "@alexkroman1/aai/ffmpeg";
await runFfmpeg([
"-hide_banner", "-loglevel", "error", "-nostdin", "-y",
"-i", inputPath,
...wavEncodeArgs({ sampleRate: 16_000, channels: 1 }),
outputPath,
]);

WavEncodeOptions

string[]

A failed ffmpeg run, with the diagnosis attached.

stderr is the tail of ffmpeg’s own log, which is where the reason is (“Invalid data found when processing input”, “Output file #0 does not contain any stream”). kind is what a caller BRANCHES on — see the module doc’s point 3 for why a workflow step must, rather than retrying a corrupt file until its attempts run out.

  • Error

new FfmpegError(options): FfmpegError

readonly string[]

string

unknown

number | null

FfmpegFailureKind

string

Signals | null

string

FfmpegError

Error.constructor

readonly argv: readonly string[]

readonly binary: string

The binary that was spawned, and the arguments it got.

readonly exitCode: number | null

Exit status, or null when the child was killed by a signal.

readonly kind: FfmpegFailureKind

readonly signal: Signals | null

The signal that killed it, when one did.

readonly stderr: string

The tail of the child’s stderr — ffmpeg’s log.

FfmpegFailureKind = "exit" | "timeout" | "aborted" | "missing-binary" | "output-too-large"

Which way a run failed — see FfmpegError.


FfmpegRunOptions = object

optional binary?: string

The binary to spawn. Defaults to AAI_FFMPEG_PATH, FFMPEG_PATH, then ffmpeg.

optional cwd?: string

Working directory for the child, so relative paths in args resolve.

optional maxOutputBytes?: number

Cap on captured stdout. Defaults to 64 MiB (DEFAULT_MAX_FFMPEG_OUTPUT_BYTES).

optional signal?: AbortSignal

Kill the run when this aborts. Combined with timeoutMs, not replaced by it.

optional stdin?: Uint8Array

Bytes to write to the child’s stdin — read them in the argv as pipe:0.

optional timeoutMs?: number

Wall-clock budget. Defaults to 10 minutes (DEFAULT_FFMPEG_TIMEOUT_MS).


FfmpegRunResult = object

durationMs: number

Wall-clock milliseconds the child ran for.

stderr: string

The tail of ffmpeg’s log, on SUCCESS too: it carries the encode summary.

stdout: Uint8Array

Whatever the child wrote to stdout — empty for a run that wrote to a file.


FfmpegSource = string | Uint8Array

A media input: a filesystem path, or the bytes themselves.


MediaInfo = object

What parseProbeJson makes of one media file — see @alexkroman1/aai/ffmpeg.

optional audio?: MediaStreamInfo

The first audio stream — the one an audio pipeline almost always means.

optional bitRate?: number

Overall bit rate in bits per second.

optional durationSec?: number

Duration in seconds, or undefined when the container does not say.

optional format?: string

ffprobe’s format name(s), e.g. "wav", "mov,mp4,m4a,3gp,3g2,mj2".

raw: unknown

ffprobe’s parsed JSON, verbatim, for a field this type does not name.

optional sizeBytes?: number

File size in bytes, as ffprobe measured it.

streams: MediaStreamInfo[]

Every stream, in ffprobe’s order.

optional video?: MediaStreamInfo

The first video stream.


MediaStreamInfo = object

One elementary stream inside a container.

optional channels?: number

Channel count (audio).

optional codec?: string

Decoder name, e.g. "pcm_s16le", "aac", "h264".

optional durationSec?: number

Stream duration in seconds, when the container declares a per-stream one.

optional height?: number

index: number

ffprobe’s own stream index — what -map 0:<index> names.

kind: string

"audio", "video", "subtitle", "data", …

optional sampleFormat?: string

Sample format, e.g. "s16", "fltp" (audio).

optional sampleRate?: number

Samples per second (audio).

optional width?: number

Pixel dimensions (video).


ProbeOptions = Omit<FfmpegRunOptions, "stdin" | "binary"> & object

optional binary?: string

The ffprobe binary. Defaults to AAI_FFPROBE_PATH, FFPROBE_PATH, then ffprobe.


TranscodeToWavOptions = WavEncodeOptions & Omit<FfmpegRunOptions, "stdin">


WavEncodeOptions = object

optional bitsPerSample?: 16 | 24 | 32

Sample width, 16 or 24 or 32 bits. Defaults to 16.

optional channels?: number

Output channel count. Omit to keep the input’s. 1 is what STT wants.

optional sampleRate?: number

Output sample rate. Omit to keep the input’s.