Everything you can configure for a rivet job lives on one struct,
OutputSpec. You build it (constructor +
chained with_* setters), optionally validate it, then run it. This page
documents every knob; for the internals see pipeline & architecture,
and for the CLI equivalents see the CLI reference.
use rivet::{OutputSpec, Rung, Quality, AudioCodecPolicy, EncodePolicy,
ChunkSeamMode, PerceptualTarget, run_job_blocking, fn_sink};
use rivet::progress::RungProgress;
use std::sync::Arc;
// A 3-rung single-file ladder, fully specified.
let spec = OutputSpec::single_file(vec![
Rung::new(1920, 1080).with_quality(Quality::crf(28)),
Rung::new(1280, 720).with_quality(Quality::target(PerceptualTarget::Standard)),
Rung::new(854, 480), // default quality
])
.with_audio(AudioCodecPolicy::Auto) // passthrough / transcode / drop
.with_max_frame_rate(30.0) // cap output cadence
.web_sdr() // color preset: BT.709 8-bit SDR
.encode_policy(EncodePolicy::AllGpus) // chunk-encode across every GPU
.chunk_seam_mode(ChunkSeamMode::ParallelConstQp); // keep seams quality-flat
spec.validate()?; // fail fast on incoherent specs
let bytes = std::fs::read("input.mkv")?;
let sink = Arc::new(fn_sink(|p: RungProgress| {
println!("{:<6} {:?} {:>5.1}% {} frames", p.label, p.status, p.percent, p.frames_done);
}));
let out = run_job_blocking(&bytes, &spec, Some("out_dir".as_ref()), sink)?;Just want one file in, one file out? Skip the spec entirely:
rivet::transcode_file("in.mkv", "out.mp4")?uses sensible defaults (source-resolution single rung, AAC/Opus passthrough, 8-bit SDR, all GPUs).
| Constructor | Output |
|---|---|
OutputSpec::single_file(rungs) |
One self-contained faststart MP4 per rung (video + audio; AV1 by default — set with_video_codec for H.264/H.265). |
OutputSpec::hls(rungs, segment_seconds) |
A segmented CMAF/HLS package: master.m3u8 + an audio rendition group + video/<h>p/{init.mp4, seg-*.m4s, playlist.m3u8} per rung, segment-aligned for clean ABR. |
rungs is a Vec<Rung> (next section). segment_seconds is the HLS target
segment length (segments still break on keyframes). The constructor wires the
matching Container + Muxer + OutputMode for you.
A Rung is one rendition: a target size + a
per-rung Quality.
Rung::new(1280, 720) // auto label "720p", default quality
.with_quality(Quality::crf(28)) // or .with_quality(Quality::target(..))
.with_label("hd") // override the auto labelRung method |
Effect |
|---|---|
Rung::new(width, height) |
A rung at width × height; label auto-set to "<short-side>p", default quality. |
.with_quality(Quality) |
Set the per-rung encoder quality. |
.with_label(impl Into<String>) |
Override the auto label. |
.short_side() |
The "p" number (min(width, height)). |
Public fields: width, height, label, quality.
Quality::crf(28) // constant rate factor (lower = better)
Quality::target(PerceptualTarget::High) // perceptual target instead of a CRFQuality field |
Type | Meaning |
|---|---|---|
crf |
Option<u8> |
Constant rate factor, encoder-native (rav1e/NVENC 0..=255). None → derive from target. |
speed_preset |
Option<u8> |
Encoder-native speed preset. None → derive from tier. |
target |
QualityTarget |
Perceptual target (used when crf is None). |
tier |
SpeedTier |
Speed/efficiency tier (used when speed_preset is None). |
keyframe_interval |
Option<u32> |
GOP length in frames. None → 2 × fps (a 2-second GOP). |
overrides |
EncodeOverrides |
Backend-agnostic per-rung knobs layered on the target/tier — a quality shift in libaom-CQ steps, tiles, reference frames, lookahead, B-frames. Inert by default. |
Quality::crf / Quality::target are the two constructors; set the rest with
struct-update syntax, e.g. Quality { tier: Speed::Archive, keyframe_interval: Some(120), ..Quality::crf(30) }, or .with_overrides(EncodeOverrides { .. }).
VMAF as the target. QualityTarget::Vmaf(n) aims every rung at a VMAF
score; codec::encode::tuning turns it into each backend's quantiser through
calibrated anchor tables, so Vmaf(93) means the same perceived quality on
NVENC, QSV, AMF and rav1e (docs/av1-tuning-research.md has the tables and
docs/av1-tuning-methodology.md how to re-calibrate for a new encoder). On
every surface it is the word vmaf=93 — --target, target= on the socket,
target in the API/manifest, target=vmaf=93 in the policy grammar. Whether a
target delivers its VMAF is measured, not assumed: bench/
scores a ladder against its source with libvmaf.
GOP. OutputSpec::gop (with_gop, CLI --gop, key gop) sets the
keyframe cadence for every rung — and, on the multi-GPU single-file path, the
chunk grid, since a chunk is a whole number of GOPs. For HLS the segment grid is
segment_seconds; a GOP shorter than the segment adds keyframes inside it, a
longer one is silently the segment. A rung's own Quality::keyframe_interval
wins over the spec-wide value.
A ladder wants different knobs at different positions: softer going down (the
same quantizer at a quarter of the resolution is a far finer quantizer in
terms of what an eye can resolve), one tile below 4K, more reference frames.
Rather than hand-setting overrides on every rung, give the spec a
RungPolicy and the engine
resolves it against each rung's position before encoding, layering the rung's
own overrides on top (the rung-specific knob wins; quality deltas
accumulate):
use codec::encode::tuning::RungPolicy;
// The measured recommendation: +2 steps softer per rung going down, no top
// bonus, one tile below 4K, three reference frames.
let spec = OutputSpec::hls(rungs, 4.0).with_rung_policy(RungPolicy::recommended());
// Or the text grammar (what `--encode-policy` and the settings key take):
let policy: RungPolicy = "qstep=2;top:q=-2;short<=2159:tiles=1x1;any:refs=3".parse()?;The grammar: rules separated by ;, each selector:key=value,..., later
wins; selectors any/top/below_top/step=N/short<=N/short>=N; keys
q, tiles (CxR), gop, lookahead, bframes, refs, multipass,
grain, speed, target (vmaf=N allowed); qstep=N alone is the
compounding per-rung step. An empty policy — the default — changes nothing.
LadderPolicy is the
recommendation as numbers, for tuning one of them.
QualityTarget(re-exported asPerceptualTarget):VisuallyLossless,High,Standard,Low,Vmaf(u8)(target a specific VMAF score).SpeedTier(re-exported asSpeed):Draft(fastest),Standard,Archive(slowest/most efficient).
Don't want to hand-write rungs? Derive a standard ABR ladder from the source:
let rungs = rivet::standard_ladder(source_w, source_h, /* max_short_side */ 1080);
let spec = OutputSpec::single_file(rungs);It snaps to standard short sides (2160/1440/1080/720/480/360/240), preserves aspect ratio, even-aligns dims, and caps the top rung.
AudioCodecPolicy |
Behavior |
|---|---|
Auto (default) |
Passthrough AAC / Opus / AC-3 / E-AC-3 verbatim; transcode MP3 / Vorbis → Opus; drop anything else. |
ForceOpus |
Always produce Opus (passthrough Opus, transcode everything else). |
Drop |
Video-only output. |
spec.with_audio(AudioCodecPolicy::ForceOpus)Two orthogonal axes. Most callers use a preset; the low-level setters are there when you need them.
spec.web_sdr() // BT.709 8-bit SDR, tonemap any HDR source down (the default)
spec.hdr10() // BT.2020 + PQ, 10-bit, no tonemap
spec.hlg() // BT.2020 + HLG, 10-bit, no tonemap
spec.passthrough() // keep the source's color + bit depth verbatimUnder the presets are exactly two methods:
| Method | Sets | Values |
|---|---|---|
with_color(ColorPolicy) |
gamut + transfer + tonemap decision | TonemapToSdr (default) · Passthrough · Hdr10 · Hlg |
with_bit_depth(BitDepth) |
bits per sample | Auto (default — follow the color policy) · EightBit · TenBit |
There is intentionally no with_gamut / with_transfer / with_color_space
— ColorPolicy bundles them because only a few combinations are web-safe:
- Gamut = the color primaries (which colors are representable): BT.709 (standard SDR) or BT.2020 (wide, for HDR).
- Transfer = the transfer function / EOTF (the curve mapping stored values ↔ light, i.e. the brightness response): SDR gamma (~2.2/2.4), PQ (SMPTE ST 2084, absolute brightness — HDR10), or HLG (ARIB STD-B67, relative — broadcast HDR).
ColorPolicy |
Gamut | Transfer | Bit depth (with Auto) |
Tonemap |
|---|---|---|---|---|
TonemapToSdr |
BT.709 | gamma | 8-bit | HDR → SDR |
Passthrough |
source | source | source | no |
Hdr10 |
BT.2020 | PQ | 10-bit | no |
Hlg |
BT.2020 | HLG | 10-bit | no |
The on-disk pixel format follows from bit depth: 8-bit → yuv420p, 10-bit →
yuv420p10le (4:2:0). HDR needs a 10-bit encoder (nvidia, amd,
or qsv — the software fallback is 8-bit); validate() rejects an HDR
request a build can't produce.
HDR is tagged in the container via colr/mdcv/clli atoms.
VideoCodecPolicy (the video analogue of AudioCodecPolicy)
is Av1 (default), H264, or H265. It resolves to the encoder/muxer's
low-level VideoCodec via VideoCodecPolicy::codec().
use rivet::VideoCodecPolicy;
let spec = OutputSpec::single_file(rungs).with_video_codec(VideoCodecPolicy::H264);AV1 is the royalty-clean default (AV1 + Opus in MP4 = zero royalty exposure);
H.264 / H.265 are for legacy-player compatibility and carry the
patent-licensing obligations AV1 was chosen to avoid. All three work for
single-file MP4 and CMAF/HLS — the muxer emits av01/avc1/avc3/hvc1/
hev1 sample entries with the matching config box and CODECS= string.
H.265 encodes 8- or 10-bit (Main / Main 10 4:2:0) on NVENC + QSV — hardware-
validated on RTX 3090 and Intel Arc — so with_bit_depth(TenBit) / a HDR
ColorPolicy works for H.265 too. H.264 is 8-bit only: there is no hardware
Hi10P profile on NVENC (no High 10 GUID) or QSV (no AVC High 10 in oneVPL),
so a 10-bit H.264 request is capability-rejected, not down-converted. The encoder
backend is chosen per GPU vendor: NVENC + QSV encode H.264/H.265; AMF and the
software encoder currently reject them (a follow-up). The same string vocabulary
(av1/h264/h265) drives the CLI --codec, the codec= settings key, the
batch manifest codec:, and the HTTP codec field.
Cap the output cadence; the source cadence is otherwise preserved.
spec.with_max_frame_rate(30.0) // never exceed 30 fpsPer-frame transforms — geometry (crop, pad, flip, rotate, grayscale), an image
overlay (PNG logo/watermark with alpha), and colour (invert, brightness,
contrast, saturation) — applied to the decoded source once, before per-rung
scaling, so a filter applies to every rendition. spec.filters is a list of
codec::filter::VideoFilter:
spec.with_filters(vec![
VideoFilter::Crop { w: 1920, h: 1080, x: None, y: None },
VideoFilter::Overlay { image: "logo.png".into(), x: 24, y: 24 },
]);
// or parse the equivalent ffmpeg-style string form:
spec.with_filters(codec::filter::parse_chain("crop=1920:1080,overlay=logo.png:24:24")?);See Video filters for the full filter set, the string + structured-object forms, and per-surface usage.
Which cards, and how the decode and the encode are laid across them. Two questions, two enums, and each enum answers its question whole — so no two settings can contradict each other ("pin decode to card 2" and "split the decode across every card" are not both sayable).
| Method | Effect |
|---|---|
encode_policy(EncodePolicy) |
The encode plan (below). |
with_gpu_index(u32) |
Shorthand for encode_policy(SingleGpu(Some(idx))). |
decode_policy(DecodePolicy) |
The decode plan (below). |
EncodePolicy — which cards encode, and how the work is laid across them:
| Variant | Meaning |
|---|---|
AllGpus (default) |
Every capable card, ladder-scheduled: one worker per card, each serving every rung and taking the next chunk of whichever rung is furthest behind. A card idles only when the whole job is out of work, and a ladder deeper than the GPU count still costs one decode. Measured faster than the pinned shape. |
PerRung |
Every capable card, each worker pinned to its own rungs (rung i to worker i mod workers) — "one rung, one GPU" when the ladder fits the pool. Predictable placement, and a rung's chunks all come off one card, at the cost of cards idling when their rungs are blocked. For benchmarking against AllGpus, and for hosts where placement matters more than throughput. |
SingleGpu(Option<u32>) |
One card — pinned to Some(i), or the first with None — one encoder per rung, serial. Single-file output is seam-free by construction (there are no chunks); HLS runs one worker. |
Family(GpuFamily) |
Every card of one vendor (GpuFamily::{Nvidia, Amd, Intel}), ladder-scheduled — e.g. ignore an integrated GPU. |
DecodePolicy — which card(s) decode, and whether the decode is one pump or
split into ranges:
| Variant | Meaning |
|---|---|
Auto (default) |
Split the decode into one range per decode-capable card of the encode set, where the source allows (an un-spliced H.264/H.265 input whose keyframes fall on chunk boundaries — see plan_decode_ranges); whole otherwise. The cards decode different stretches of the source at the same time; the numbering stays continuous across the join and the output is byte-identical to a whole-source decode. |
Whole |
One decoder for the whole source, on the first capable card of the encode set. What every job did before ranges existed; the control arm of any comparison. |
SpecificGpu(u32) |
One decoder pinned to that card (e.g. decode on an iGPU while the dGPUs encode). Never split — a split on one card is no split. |
FastestGpu |
Benchmark every decode-capable card on a short prefix of the input and put one decoder on the quickest. A no-op on single-GPU hosts. |
Ranges(usize) |
Split into up to this many ranges, round-robin over the capable cards. More ranges than cards is legal (several pumps share a card) and is how the split is exercised on a one-card host. |
Both apply to HLS and to multi-GPU single-file alike (single-file's unit is a
chunk of several GOPs stitched back into one MP4 — see
§8 for the seams). CLI:
--encode all|per-rung|single|gpu:N|family:VENDOR,
--decode auto|whole|fastest|gpu:N|ranges:N; settings keys encode,
decode; the older --gpu / --single-gpu / --gpu-family / --decode-gpu
still work as spellings of the same choices.
spec.encode_policy(EncodePolicy::Family(rivet::GpuFamily::Nvidia))
.decode_policy(DecodePolicy::SpecificGpu(0)); // decode on GPU 0, encode on the NVIDIA cards
spec.encode_policy(EncodePolicy::PerRung) // one rung, one GPU
.decode_policy(DecodePolicy::Whole); // one decoder, e.g. to A/B the splitOnly relevant when multiple GPUs encode a single file: each rung is chunked at GOP boundaries, encoded in parallel, and stitched. Each chunk is an independent IDR-led GOP so it always plays, but per-chunk rate control can step quality at the ~2 s seams. This knob governs that (chiefly for NVENC, which otherwise runs VBR per chunk; AMD/QSV chunks are already constant-QP):
ChunkSeamMode |
Seams | Speed |
|---|---|---|
Parallel (default) |
possible mild NVENC steps | fastest (all GPUs) |
ParallelConstQp |
flat (forced constant-QP, quality still tracks the target) | fast (all GPUs) |
Serial |
none (one encoder for the whole file) | slower; HLS still uses every GPU |
Single-GPU hosts, --gpu/SingleGpu, and HLS jobs are unaffected (HLS segments
are independent by design).
spec.validate()?;Rejects incoherent specs before any work starts: no rungs, zero/odd dimensions,
container/muxer/mode mismatch, HDR with forced 8-bit, or 10-bit/HDR on a build
with no 10-bit encoder (queryable at runtime via
codec::encode::build_output_caps()).
| Function | Use |
|---|---|
rivet::transcode_file(input, output) |
One file → one file, default spec. Returns a TranscodeOutcome. |
rivet::transcode_bytes(&bytes, ..) |
The in-memory variant. |
rivet::run_job_blocking(&bytes, &spec, out_dir, sink) |
Run a full OutputSpec synchronously. out_dir: Option<&Path> (the HLS/multi-rung asset root; None = temp dir). Returns JobOutput. |
rivet::run_job(&bytes, &spec, out_dir, sink).await |
The async variant (drive from a Tokio runtime). |
rivet::probe_file(path) / probe_bytes(&bytes) |
Inspect without transcoding → MediaInfo. |
Both run_job* take a ProgressSink that streams a uniform
RungProgress per rung — label, status
(RungStatus: Pending → Running → Completed/Failed), percent,
frames_done, segment + byte counters. Wire it however you like:
use std::sync::Arc;
// a closure
let sink = Arc::new(rivet::fn_sink(|p| println!("{} {:.0}%", p.label, p.percent)));
// or a Tokio channel (async)
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let sink = Arc::new(rivet::channel_sink(tx));OutputSpec |
Signature | Section |
|---|---|---|
single_file |
(Vec<Rung>) -> Self |
1 |
hls |
(Vec<Rung>, f32) -> Self |
1 |
with_audio |
(AudioCodecPolicy) -> Self |
3 |
with_max_frame_rate |
(f64) -> Self |
5 |
with_color |
(ColorPolicy) -> Self |
4 |
with_bit_depth |
(BitDepth) -> Self |
4 |
web_sdr / hdr10 / hlg / passthrough |
() -> Self |
4 |
with_gpu_index |
(u32) -> Self |
6 |
encode_policy |
(EncodePolicy) -> Self |
7 |
decode_policy |
(DecodePolicy) -> Self |
7 |
chunk_seam_mode |
(ChunkSeamMode) -> Self |
7 |
with_rung_policy |
(RungPolicy) -> Self |
2 |
validate |
(&self) -> Result<()> |
8 |
tonemaps |
(&self) -> bool |
(does this spec tonemap?) |
resolve_output |
(ColorMetadata, PixelFormat) -> (ColorMetadata, PixelFormat) |
(resolve color/depth vs a source) |
All OutputSpec fields are pub, so anything above can also be set directly
(spec.color = ColorPolicy::Hdr10;): mode, video_codec, audio, container,
muxer, rungs, max_frame_rate, gpu_index, encode_policy, decode_policy,
color, bit_depth, chunk_seam_mode, rung_policy. The builders are the recommended path
(they keep linked fields — e.g. gpu_index and encode_policy — in sync).