Skip to content

--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).

Terminal window
# 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.mp4

A --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 filenamepresets/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:

targetRich in a windowRich through --rendersamples per output pixel, rendered
640x360150,000150,0000.651
1280x720600,000600,0000.651
1920x1080600,0001,350,0000.651
2560x1440600,0002,400,0000.651
3840x2160600,0002,700,0000.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 attier 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 shot otherwise works to keep, and --render gives it up on purpose. --frame-at and every other mode here stay on the window’s ceiling, so a still and a render of the same instant differ at 1080p Rich.
  • Floor never moves in a window, at any size. Its live ceiling is its anchor: 1080p at Floor on 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 --render it scales like Rich does.
  • 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 --fps refuses a decimal. 29.97 is 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.

Terminal window
# 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=unknown

from 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. --out is therefore rejected without --ffmpeg and required with it.

  • Nothing validates the container. That the frames and the stream are right is tested; whether ffmpeg made a good MP4 is outside this harness and ADR-0114 accepts it.

  • --render takes its own clip and is mutually exclusive with --signal, --audio and --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 rich is 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_mitosis at --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.