--render: a music video from a track
Every other mode on this page takes a picture. --render walks a WAV end to
end at a fixed frame step and writes a video stream to stdout, for a user’s
own ffmpeg to encode (ADR-0114).
# One command: --ffmpeg spawns the encoder and wires the pipe.cargo run -p standalone --example shot -- \ --preset "Supernova" --render track.wav --fps 30 --size 1920x1080 \ --ffmpeg ffmpeg --out track.mp4A --preset that names nothing costs nothing. The name is checked against
the roster before the encoder is spawned and before a GPU device is built, so a
typo exits 1, lists the roster’s keys, and writes no file at all. That check
sits where it does because ffmpeg exits 0 on a frame stream that never carried
a frame: rejecting the name any later left a valid, playable, audio-only MP4 at
the destination, a few hundred bytes that a glance cannot tell from a short
render. If you have such a file on disk from an older build, that is what it is.
The roster is keyed on a preset’s name field, not on its filename —
presets/attractor_leviathan.toml is --preset "Leviathan". That is the
confusion the key list in the error exists to settle.
No encoder ships, and that is a decision rather than an omission. A 1080p
RGBA frame is 8.29 MB and four minutes at 60 fps is 119 GB, so the frames can
never reach disk before the encoder — a pipe is the only viable shape, not an
optimization. A static encoder is in turn larger than this application’s whole
size budget. So ffmpeg is a documented
prerequisite for this one feature, and ritmolux.exe does not change size.
What makes this worth having is that it cannot drop a frame. Every live
visualizer’s render loop is welded to a real-time audio device, so its “export”
is a screen capture: bounded by the display’s refresh and resolution, degraded
under load, and different every run. Nothing in this path races a display — dt
is injected (ADR-0013), the DSP is a pure function of its input window
(NFR §6), and the grammar’s randomness is pinned
(ADR-0051). Two runs of the same command produce byte-identical streams, and
that is asserted in standalone/tests/shot_cli.rs rather than inferred.
A render draws the attractor denser than a window does, deliberately
(ADR-0140).
The attractor’s sample budget is a density against the render target rather
than a flat tier constant —
clamp(round(anchor * target_px / 230400), anchor, ceiling), anchored at
640x360, whose density was the accepted one. There are two ceilings, and
--render is the only path in this repo that takes the larger:
| target | Rich in a window | Rich through --render | samples per output pixel, rendered |
|---|---|---|---|
| 640x360 | 150,000 | 150,000 | 0.651 |
| 1280x720 | 600,000 | 600,000 | 0.651 |
| 1920x1080 | 600,000 | 1,350,000 | 0.651 |
| 2560x1440 | 600,000 | 2,400,000 | 0.651 |
| 3840x2160 | 600,000 | 2,700,000 | 0.326 |
The window column stops at 600,000 because a display has a frame to hit and the
buffer is paid for in every window; a render answers to memory instead, so a
1080p file reaches the reference density outright. --render prints the number
it drew at — tier rich, attractor samples 1350000 in the header — which is
the only way to tell two files apart afterwards.
Three consequences worth knowing before you read a rendered file as evidence:
- A rendered file is not the frames the app would have drawn at that size.
That is the one property
shototherwise works to keep, and--rendergives it up on purpose.--frame-atand every other mode here stay on the window’s ceiling, so a still and a render of the same instant differ at 1080pRich. Floornever moves in a window, at any size. Its live ceiling is its anchor: 1080p atFlooron integrated hardware already sits on the 16.67 ms budget at today’s 50,000 (NFR §1), so the law is a no-op there. Through--renderit scales likeRichdoes.- A render’s resident set is ~950 MB whatever its size, because the particle buffer is allocated once at the ceiling rather than at what the target asked for: measured 956 MB peak at 1920x1080 and 952 MB at 640x360, flat across 480 frames either way (growth -7.9 MB and +0.1 MB). The offline ceiling is 2,700,000 particles at 48 B, and the process holds it twice — once on the GPU and once as the CPU scatter it re-uploads from.
The two clocks are different clocks. Analysis hops arrive at
sample_rate / HOP_SIZE — 93.75 Hz for 48 kHz audio — and frames at --fps.
The loop advances whichever is due next, so most 60 fps frames take one new hop
and some take two. Rendering one frame per hop instead would run the picture at
64% speed against its own soundtrack. Above the hop rate (--fps 240) frames
repeat the last published analysis frame rather than interpolating one: the DSP
publishes on hop boundaries, and inventing values between them would put
something on screen the analyzer never derived.
The wire format is Y4M (ffmpeg -f yuv4mpegpipe), and the stream is
self-describing — YUV4MPEG2 W1920 H1080 F60:1 Ip A1:1 C444 XCOLORRANGE=FULL —
so a mistyped geometry cannot silently produce garbage and a non-ffmpeg
consumer needs nothing from the command line. Three parts of that header earn
their place:
C444— chroma is not subsampled, so the conversion loses only rounding.XCOLORRANGE=FULL— the samples use 0–255, not the 16–235 studio swing. Omit it and every player expands the range again and the file is visibly washed out against the app, in a way that reads as an engine bug and is not.F60:1— an exact rational, which is why--fpsrefuses a decimal.29.97is 30000/1001; accepting it as 2997/100 would drift the picture against its own soundtrack by a frame every few minutes, and nothing in this harness would catch it.
Y4M cannot carry RGB — the muxer errors on rgb24 — so shot owns the
RGB→YUV conversion (full-range BT.709). It is not bijective at 8 bits, which is
why the tap-placement guard is asserted on the RGB frame the writer is handed and
never on the wire bytes; a guard written against the wire would have to be
loosened to a tolerance until it passed, which is how a guard becomes decoration.
The one canonical ffmpeg invocation
--ffmpeg <path> spawns the encoder, wires the frame stream into its stdin, and
passes the source WAV through as a second input for muxing. There is exactly
one command line and --ffmpeg generates it — it is echoed on stderr at the
start of every encoded run, so adapting it by hand starts from what actually
ran rather than from a wiki of incantations:
ffmpeg -hide_banner -nostats -y -f yuv4mpegpipe -i pipe:0 -i track.wav \ -map 0:v:0 -map 1:a:0 \ -c:v libx264 -preset medium -crf 18 \ -pix_fmt yuv420p -color_range pc -colorspace bt709 -color_primaries bt709 \ -color_trc bt709 -x264-params colorprim=bt709:transfer=bt709 \ -c:a aac -b:a 192k -shortest track.mp4-crf is the one argument you may move, and --crf <0-51> moves it.
Everything else in that command line describes the stream — geometry, mapping,
colour — and a lever on any of those would be a way to mistype what is already on
the wire. The default of 18 is archival and deliberately not shareable: on a 30 s
slice of attractor_leviathan at 1080p60 rich it is 119 Mbit/s, against
60 at -crf 23 and 27 at -crf 28, where a typical 1080p60 upload
recommendation is about 12. Lower is bigger and better; the scale is roughly
logarithmic, so +6 is about half the size. The default does not move, because a
capture is evidence first — re-encoding down from an archival master is lossy but
possible, and the reverse is not.
# The same render at a shareable size.cargo run -p standalone --example shot -- \ --preset "Supernova" --render track.wav --fps 30 --size 1920x1080 \ --ffmpeg ffmpeg --out track.mp4 --crf 23--crf needs --ffmpeg, and says so rather than being ignored: without an
encoder there is no command line for it to appear in. The other size-control
route is the raw-stream path — omit --ffmpeg, redirect stdout, and run your
own encoder with whatever rate control, codec, or two-pass recipe you want. That
path is the one that has always been there and --crf does not replace it; what
it buys is that the common case stays one command line.
No -s or input -pix_fmt: the geometry is on the wire, which is the point of a
self-describing stream. The -map pair is explicit so a clip carrying album art
cannot displace the rendered picture. The four colour tags are the half most
likely to ship wrong — an untagged file is one the player expands from studio
swing and shows washed out. -color_trc bt709 rather than iec61966-2-1: the
tap hands over sRGB-encoded samples and the two are close, but every player
assumes the former and some ignore the latter outright.
Two of those four need saying twice, and that is why -x264-params is on the
line. The -colorspace flag is honoured by the libx264 path; -color_primaries
and -color_trc are dropped by it. That was measured on ffmpeg 8.1 (gyan.dev
full build) and on no second build. A file written with all three reads back
color_range=pc color_space=bt709 color_primaries=unknown color_transfer=unknownfrom ffprobe -v error -select_streams v:0 -show_entries stream=color_range,\ color_space,color_primaries,color_transfer. It is not a reporting convention:
asking the same command for bt2020/smpte2084 moves the matrix and leaves the
other two unknown, so the loss is in the encoder wrapper. Setting them on x264
directly lands all four, and the flags stay beside it — they carry the same
values and are what a build that honours them reads. Nothing about the encoded
picture changes; this is metadata. the_four_colour_tags_survive_into_the_container
in standalone/tests/shot_cli.rs reads them back off a produced file wherever
ffmpeg and a GPU are both present, so the claim above is checked rather than
asserted.
A dead encoder reports the encoder’s failure, not our broken pipe. The child’s
stderr is drained on a thread — echoed line by line as it arrives, and kept — so
if it exits non-zero shot exits non-zero quoting its last words. Writing into a
full pipe blocks until the encoder drains it, which is the backpressure
handling: nothing is buffered on this side of it. Both halves are asserted in
standalone/tests/shot_cli.rs, the second against a stand-in encoder that dies
on its first argument.
ffmpeg is a prerequisite, and its absence is a named error naming the flag —
never a fallback to something else, because a quietly-substituted encoder is what
would make an exported file untrustworthy.
Practical notes:
-
stdout is the video on the raw-stream path. Every human-readable line goes to stderr, so a summary printed the way the other modes print one would be eight bytes of garbage in the middle of the file.
--outis therefore rejected without--ffmpegand required with it. -
Nothing validates the container. That the frames and the stream are right is tested; whether
ffmpegmade a good MP4 is outside this harness and ADR-0114 accepts it. -
--rendertakes its own clip and is mutually exclusive with--signal,--audioand--horizon— any pair would mean silently ignoring one of two stimuli. -
The frame count is
ceil(clip_seconds x fps): the trailing partial frame is rendered rather than dropped, so the picture is never shorter than the audio. -
It renders at the floor tier like every other capture path.
--tier richis the opt-in, and it is the mode where it is most worth paying for — an offline render has no 60 Hz deadline, so the frame-time governor never fires. -
Every run reports its resident set, sampled across the render and printed on stderr at the end:
render: resident set 434 MB, growth +0.1 MB across 600 frames after a+75.9 MB warm-up (peak 434 MB, 21 samples)A render that leaks is the same defect as a live session that leaks, so NFR §12’s no-session-growth requirement applies here and is measured the same way. Read the growth, not the absolute: the latter is a ~327 MB vendor driver floor on the reference box and does not travel. Growth is charged from the warm reading rather than the baseline because the whole warm-up step lands at once on the first draw — pipelines compiled, GPU resources built — and charging it against the baseline would print a flat run as “+76 MB”, which is exactly what the per-frame retention Plan 0099 found looks like. The peak keeps both honest: a run that grew and was reclaimed reads flat end to end, and only an intermediate sample tells it apart.
The full four minutes at 1080p/60 — 14,400 frames of
reaction_mitosisat--tier rich, the thirteen-pass family that hit 0099’s wall — measured 334 MB, growth -8.1 MB, peak 342 MB on the Windows dev box (2026-08-17, hardware adapter, release). Where the old retention would have reached ~4.4 GB.
A filter stage between shot and the encoder
The frame stream is a pipe, so anything that speaks Y4M can sit in the middle of
it. tools/sd-filter/ is the first such stage: an img2img diffusion pass with
ControlNet holding the render’s geometry, so the attractor becomes canyon rock
while the shape keeps tracking the music. It is documented in one place, and
that place is Diffusion filter — setup, the one
canonical command, the flags and what it costs. Nothing on that path ships in the
release zip.
--ffmpeg is not used there: it spawns the encoder itself, which leaves no
seam to insert a stage into. Composing the pipe by hand is the whole point of the
raw-stream path.
The encoder line is invariant, and that is the pipe-level fact worth keeping
here. A stage in the middle changes the picture and never the frame count, so
the ffmpeg invocation above never learns a rate that has to agree with a flag on
another process. There is no -r to keep in sync and no way to desynchronize the
audio silently — which is why the encoder half of that pipe is unchanged,
character for character, whether or not a stage is in it.
Built from a8ce055 at version 0.115.0. This site tracks main and is not versioned per release.