Normalized options → ffmpeg argument vector (API doc §3).
This is the last leg of the round-trip the project is built around: parse →
normalize → cache key → identical ffmpeg args. build/3 is a pure
function of a validated AudioProxy.Options.t/0 and the input URL, so two
option strings that normalize alike produce byte-identical argv, which is
what makes a cache key a promise about bytes rather than about a URL.
iex> {:ok, opts} = AudioProxy.Options.parse("f:opus/br:96/t:12.5:30/fade:0.5:1")
iex> AudioProxy.Ffmpeg.Command.build(opts, "https://example.test/a.wav", type: :http)
["-nostdin", "-hide_banner", "-loglevel", "error",
"-protocol_whitelist", "https,tls,tcp",
"-ss", "12.5", "-t", "30", "-i", "https://example.test/a.wav",
"-vn", "-sn", "-dn", "-af", "afade=t=in:st=0:d=0.5,afade=t=out:st=29:d=1",
"-c:a", "libopus", "-b:a", "96k", "-f", "ogg", "pipe:1"]Shape of the argv
Arguments are emitted in a fixed order and never conditionally reordered — list equality is the tested contract, so a stable shape is the whole point:
- baseline flags (
-nostdin -hide_banner -loglevel error), - the input protocol whitelist, before
-iso it binds the input, - input-side seek (
-ss/-t) before-i, - the input URL as one argv element,
-vn -sn -dn(we ship audio, and nothing else — see Audio only),- the filtergraph, then
-ac, then the codec/muxer arguments, -f <muxer> pipe:1.
Seeking before -i is not a micro-optimization: ffmpeg's HTTP client turns
it into a Range request, so t:3600:30 on a two-hour master reads the
thirty seconds it needs and nothing else. That is the reason sources are
handed to ffmpeg as presigned URLs instead of piped through the BEAM.
Because the seek happens on the input, the trimmed region starts at t=0 for
everything downstream, which is exactly the frame fade is specified in.
Injection safety
There is no shell. The argv is a flat list of complete arguments, so a
source URL containing ;, $(…), quotes or spaces is one element and stays
data. Nothing user-supplied reaches the filtergraph either: dl and cb
never appear in argv at all, and every filter value is a number that
AudioProxy.Options has already parsed and bounded, re-rendered here
through AudioProxy.Options.render_number/1.
No URL content can become an ffmpeg flag, either, and that is asserted
rather than argued: allowed_flags/0 publishes every flag this module can
emit, and the property test walks a generated argv position by position,
checking each flag against that list and each value against the flag it
follows. A value that happens to start with - — f:ogg/q:-1 renders
["-q:a", "-1"] — is therefore not mistaken for a flag, and a flag that
arrived from anywhere but this module has nowhere to hide.
Audio only
Every argv disables non-audio streams (-vn -sn -dn) and restricts ffmpeg's
input protocols (-protocol_whitelist), unconditionally, for every format
and for the peaks PCM path. Both are defence in depth behind the render
action's probe gate, which is what actually rejects a video source with 415:
these two are what hold if the gate is ever bypassed, reordered, or asked
about a source it cannot see inside.
The protocol set is derived from the resolved source's type, never from
the input string and never from an env knob — a knob would reopen the hole
this closes. The sets are disjoint by construction: a local source gets
file and so cannot reach the network, a remote one gets https,tls,tcp
and so cannot reach the filesystem. concat:, subfile: and friends are
reachable from neither, which is what stops a crafted or redirecting source
from pivoting ffmpeg across a boundary. See protocols/1.
Filter order
The chain is loudnorm → volume → aresample → afade, and the order is
load-bearing:
loudnormfirst, because normalizing after a staticgainwould undo it —gainwithnormmeans "normalize, then offset".aresampleafterloudnorm, because single-passloudnormresamples its output to 192 kHz. Whennormis given withoutsrwe therefore appendaresample=48000ourselves; without it every normalized render would be a 192 kHz file (or a silent auto-resample by the encoder). 48 kHz is the API's own lossy ceiling (§3.1) and universally supported, but it does meannormon a 96 kHz lossless master downsamples. Fixing that needs the source's real rate, which this module deliberately does not know.afadelast, so the fade shape survives the stages above it.
What the builder does not know
build/3 takes a source/0 because two decisions genuinely cannot be made
from the options alone. The protocol whitelist is one, and it is required.
The other is optional: with no bd, a lossless variant should follow the
source's own bit depth, the way sr follows its sample rate (§3.1). Until
the /info probe exists to supply it, the fallback is 16-bit — documented,
not silent.
Peaks
f:peaks builds raw interleaved s16le PCM on stdout — the input to
AudioProxy.Peaks, not its output. Encoding and loudness options are already
refused for peaks by AudioProxy.Options; t, ch and fade apply, since
all three change the samples a waveform would be drawn from. ch is the one
option peaks read differently: absent, it means mono rather than "follow the
source", because the reducer has to know the interleaving up front and a
waveform UI draws one shape.
Summary
Types
What the builder needs to know about the source itself.
A resolved source's type tag, as its module reports it.
Types
@type source() :: [type: source_type(), bit_depth: AudioProxy.Options.bit_depth()]
What the builder needs to know about the source itself.
:type is the resolved source's tag (AudioProxy.Source.Type.tag/0) and is
required: it is what the input protocol whitelist is derived from, and a
default would be a guess about which side of the network/filesystem boundary
this render sits on. :bit_depth is optional, and exists because a lossless
variant cannot pick a sane default without knowing the source's depth;
omitting it keeps the documented 16-bit fallback.
This does not weaken the round-trip invariant, but it is worth stating the invariant precisely. The cache key hashes the normalized options and the source, so equal keys imply the same source and therefore the same type, the same source metadata, and — within one deployment — the same argv.
The qualifier is not new to this key: input_url is what
AudioProxy.Source.ffmpeg_input/1 produced, and for a remote source that is a
presigned URL whose host, scheme and signature all come from deployment
configuration. Argv has therefore never been portable across deployments, and
protocols/1's :s3 clause (which reads AP_S3_ENDPOINT) adds nothing in
kind. What the cache needs is that argv be a deterministic function of the key
on the box serving it, and it is. Neither the presigned URL nor the protocol
set changes a single output byte, so two deployments still render identical
variants for identical keys.
@type source_type() :: :local | :s3 | :http
A resolved source's type tag, as its module reports it.
Functions
@spec allowed_flags() :: [String.t()]
Every flag build/3 can ever emit, sorted.
Published so the argv-allowlist property test compares against this module's own vocabulary rather than a copy that can drift. Two things are asserted against it: that every flag in a built argv is a member (reality ⊆ allowlist), and that the list itself contains no video, subtitle or stream- mapping flag (allowlist ∩ denylist = ∅).
@spec build(AudioProxy.Options.t(), String.t(), source()) :: [String.t()]
Builds the ffmpeg argument vector for options reading from input_url.
options must already be valid — AudioProxy.Options.parse/1 and
validate/1 are the gate, and every rule they enforce is a precondition
here (a bounded trim behind every fade-out, a lossless format behind every
bd, and so on). input_url is passed through verbatim as a single argv
element; it is never parsed, escaped, or interpolated.
source must carry the resolved source's :type — see source/0 and
protocols/1.
The result is the argument list after the program name, ready for
Port.open/2 with :args.
iex> {:ok, opts} = AudioProxy.Options.parse("f:wav/bd:24")
iex> AudioProxy.Ffmpeg.Command.build(opts, "/srv/audio/k.aif", type: :local)
...> |> Enum.take(-8)
["-vn", "-sn", "-dn", "-c:a", "pcm_s24le", "-f", "wav", "pipe:1"]With no bd, a lossless variant follows the source when its depth is known:
iex> {:ok, opts} = AudioProxy.Options.parse("f:wav")
iex> AudioProxy.Ffmpeg.Command.build(opts, "/srv/audio/k.aif",
...> type: :local, bit_depth: :bd24)
...> |> Enum.take(-5)
["-c:a", "pcm_s24le", "-f", "wav", "pipe:1"]
@spec content_type(AudioProxy.Options.t() | AudioProxy.Options.format()) :: String.t()
The Content-Type for a variant.
Takes a format atom, or an AudioProxy.Options.t/0 — peaks need the
latter, since pk_fmt decides between JSON and the compact binary form.
iex> AudioProxy.Ffmpeg.Command.content_type(:m4a)
"audio/mp4"
iex> {:ok, opts} = AudioProxy.Options.parse("f:peaks/pk_fmt:dat")
iex> AudioProxy.Ffmpeg.Command.content_type(opts)
"application/octet-stream"
@spec protocols(source_type()) :: String.t()
The ffmpeg input protocol whitelist for a resolved source's type.
One entry per source type, and deliberately not configurable: the whole point is that the set follows from what the source is, so no request and no environment variable can widen it.
iex> AudioProxy.Ffmpeg.Command.protocols(:local)
"file"
iex> AudioProxy.Ffmpeg.Command.protocols(:http)
"https,tls,tcp"A source type added later has no clause here, so it raises rather than
inheriting somebody else's protocol set — the same discipline
AudioProxy.ErrorJSON applies to its own rows, and for the same reason: the
mistake should crash that slice's tests, not quietly open a protocol.
One clause is not a pure function of its argument, despite the spec: :s3
reads AP_S3_ENDPOINT, because the presigned URL ffmpeg is handed carries the
endpoint's own scheme. See source/0 for why that does not weaken the
round-trip invariant — the same endpoint is already in the input URL.
Whether flag is followed by a value argument.
The other half of what the property test needs: without it, walking an argv
cannot tell the flag -t from the value -1 that f:ogg/q:-1 renders,
and a check that only looked at leading hyphens would have to choose between
a false alarm and a hole.
iex> AudioProxy.Ffmpeg.Command.takes_value?("-b:a")
true
iex> AudioProxy.Ffmpeg.Command.takes_value?("-vn")
false