A small axum webserver (behind the server feature) so another application can
signal rivet to transcode over the network. It runs the same configurable
engine as the CLI — see pipeline & architecture for how
that engine works internally: POST media + an output spec, rivet transcodes to
AV1 and reports per-rung progress, and you fetch the artifacts.
cargo build --release --features server,nvidia # the API + an AV1 encoder
rivet serve --addr 0.0.0.0:8080Interactive documentation is served live:
| Path | What |
|---|---|
/ |
landing page linking the three below |
/swagger |
Swagger UI |
/redoc |
Redoc |
/openapi.json |
the OpenAPI 3.0 document (source of truth) |
(The UI pages load Swagger/Redoc JS from a CDN; the spec itself is served locally. An airgapped deployment can vendor the JS.)
- Submit → poll → fetch.
POST /v1/transcodereturns202 { job_id }and runs asynchronously. PollGET /v1/jobs/{id}for status + per-rung progress, then download the artifact(s). Pass"sync": truein a JSON body (or?sync=truewith a binary body) to block and get the result in one call. - Two request shapes (pick whichever fits):
- Structured JSON body (
Content-Type: application/json) — aTranscodeRequest:inputfrom a server file path or inline base64, an optional serveroutput.path, and a structuredspec. Streaming the media is not required. - Streamed binary body (
application/octet-stream) — the raw media bytes (up to 4 GiB), with the output spec in the query params (table). This is the streaming option. - With a JSON body, everything comes from the body: query parameters
(
syncandhooksincluded) are ignored.
- Structured JSON body (
- Server-side file I/O. When the JSON body names an input/output
path, the server reads/writes its own filesystem (no upload/download). SetRIVET_FILE_ROOTto sandbox those paths to a directory; otherwise any path is allowed (the server binds localhost by default — treat it as trusted-local). - The output spec — JSON
spec, query params, the CLI flags, and the IPCkey=valueheader — are all the same canonical knob set, so they map 1:1. - Concurrency. By default the server starts every job it accepts at
once: there is no limit. Limiting is the operator's choice:
rivet serve --jobs N(orRIVET_SERVER_JOBS=N; the flag wins) runs at mostNat once, and a job accepted whileNrun staysqueueduntil one ends, in arrival order. Either way each job may use every GPU encoder and decoder itsencodeplan selects (all of them by default) — the limit counts jobs, it does not give each job a card. The CPU is shared so the jobs do not oversubscribe it: a job's software encoders, decoders and thread pools get the machine divided by the jobs running when it starts them (byN, when a limit is set and that is more).
Liveness, detected GPUs, and this build's output capabilities.
curl -s http://localhost:8080/v1/health{
"status": "ok",
"service": "rivet",
"gpus": [{ "index": 0, "vendor": "Nvidia", "name": "NVIDIA GeForce RTX 3090" }],
"output_caps": {
"max_bit_depth": 8, "hdr": false,
"by_codec": [
{ "codec": "av1", "max_bit_depth": 10, "hdr": true,
"backends": [{ "backend": "nvenc", "max_bit_depth": 10, "hdr": true }] },
{ "codec": "h264", "max_bit_depth": 8, "hdr": false,
"backends": [{ "backend": "nvenc", "max_bit_depth": 8, "hdr": false }] },
{ "codec": "h265", "max_bit_depth": 10, "hdr": true,
"backends": [{ "backend": "nvenc", "max_bit_depth": 10, "hdr": true }] }
]
}
}A job is validated against the caps for its own codec: by_codec is that
answer, and the one to read. AV1 is 10-bit HDR on nvidia / amd / qsv
and on the software AV1 tier (backend av1, av1-sw-fallback: it writes the
colour description into the sequence header and the HDR10 metadata into
metadata OBUs), H.264 only on h26x-fallback (no hardware backend has a
10-bit H.264 encoder). rivet capabilities --json reports the
same block under encode.by_codec.
max_bit_depth / hdr beside it are what every output codec meets — the
lowest depth in by_codec, and hdr only when every codec has it — so a job
asking for no more passes validation whichever codec it names.
Changed 2026-09-14. These two fields used to be the union over the compiled encoders: 10-bit HDR when any codec had it. That claimed 10-bit HDR AV1 on a build whose only encoder is
h26x(no AV1 encoder at all) and 10-bit H.264 on annvidiabuild. On such builds they now read 8 /false; on a build where every codec is 10-bit HDR they read 10 /true, as before. A client that wants one codec's answer readsby_codec.
Body = media bytes → JSON media info (no transcode).
curl -s -X POST --data-binary @input.mkv http://localhost:8080/v1/probe{ "video_codec": "h264", "width": 1920, "height": 1080, "frame_rate": 30.0, "duration": 12.5 }Returns 202 { "job_id", "status": "queued" } and runs asynchronously. With
sync it blocks and returns 200 with the single-file artifact itself
(video/mp4, video/quicktime or video/webm, or audio/mpeg / audio/flac / audio/mp4 for audio-only
output) when the job made exactly one file held in memory, or else the
job status JSON: several rungs (fetch each from its
artifacts[].url), output written to output.path, or an HLS package.
JSON body (application/json) — point at a server file, no upload:
curl -s -X POST http://localhost:8080/v1/transcode \
-H 'Content-Type: application/json' \
-d '{
"input": { "path": "/data/in.mkv" },
"output": { "path": "/data/out.mp4" },
"spec": { "mode": "single", "rungs": ["1280x720"], "crf": 28, "color": "sdr" },
"sync": true
}'Body fields:
| Field | Notes |
|---|---|
input.path |
a file path on the server to read the media from |
input.base64 |
…or the media inline, base64-encoded (set exactly one of path/base64) |
output.path |
optional: write the result to a server path (a file for single-rung single-file; a directory for multi-rung / HLS). Omit to keep it in memory / stream it back. A path that resolves to input.path (by any spelling: a case variant, .., a link), or an HLS directory holding it where the package writes, is refused with a 400 before the job runs; a file is written to a temporary beside it and renamed into place |
spec |
the structured output spec — the query params below, except sync and hooks (top-level fields here) and chroma_downsample (query only). rungs is an array (["1280x720", "640x360@1M"]); bit depth is bit_depth (pixel_format is accepted too); gop, max_fps and max_short_side take a number or a word; filter takes a chain string or a structured list (see Video filters). Unknown keys are ignored |
sync |
true: block until done; returns the artifact (no output.path) or the job status JSON |
hooks |
optional server hooks to run on this job, by name: ["a", "b"]. Required hooks always run. See GET /v1/hooks |
Binary body (application/octet-stream) — stream the media, spec in the query:
job=$(curl -s --data-binary @input.mkv \
"http://localhost:8080/v1/transcode?mode=single&crf=28&audio=opus" \
| jq -r .job_id)| Param | Values / default | Notes |
|---|---|---|
mode |
single (default), hls, audio |
output shape; audio is the audio alone as one file — an .mp3 (audio/mpeg), a .flac, an .m4a or an Ogg file (audio/ogg), the codec's own unless audio_container says (also what a single job of an input with no video becomes). image is refused 400: stills are rivet image |
codec |
av1 (default), h264, h265, vp9, vp8, mpeg2, mpeg4, prores / prores-proxy / -lt / -422 / -hq / -4444 / -4444xq |
output video codec; vp9…prores are rivet's own software encoders (VP9 also as HLS, the rest single-file only) |
container |
mp4, mov, webm; default the codec's own |
the file of a single-file output (video/mp4, video/quicktime or video/webm in a synced response); a codec in a file that does not carry it is refused (400) |
prores_profile |
proxy, lt, 422, hq, 4444, 4444xq |
the ProRes profile with codec=prores |
rungs |
WxH,WxH… |
comma-separated, e.g. 1280x720,640x360. Each size is a maximum box the source is fitted into (see fit). Omit for source resolution. WxH@RATE (1280x720@3M) codes that rung to a bitrate, WxH@standard gives it the rate it would have with none named anywhere; :FIT, :auto/:fixed and :upscale/:no-upscale set that rung's own fitting (1080x1920:cover:fixed). |
fit |
contain/cover/pad/stretch |
how the source meets each box — keep its shape inside (default), fill and centre-crop, black bars to exactly the box, or stretch to it |
orientation |
auto/fixed |
auto (default): a box turns to the source's orientation |
upscale |
true/false |
let a rung be larger than the source (default false). The job status's renditions lists each requested rung's box, its output size, and which rung it merged into when it came out the same as another |
ladder |
true/false |
derive a standard ABR ladder instead of rungs |
max_short_side |
integer or standard |
cap the ladder's short side; standard states the default, 1080 |
segment_seconds |
number (default 4) |
HLS segment length |
crf |
integer | constant rate factor (names the quantiser; target is then not consulted) |
target |
visually_lossless, high, standard (default), low, vmaf=N |
perceptual quality target for every rung — same words and meaning as the CLI's --target |
gop |
integer or string | GOP length for every rung: frames (48) or seconds of output ("2s", "1.5s"); default two seconds, which "2s" states; same meaning as the CLI's --gop |
video_bitrate |
string | bitrate for every rung without its own @RATE, e.g. 3M: the rung is coded to a rate, not to target; standard states the default, none. An average rate (the default rate_mode) is coded by the software encoders only (H.264 / H.265, AV1, VP9, MPEG-2, MPEG-4 Part 2) — a job whose encode pool is GPUs is refused by name; a constant one by the GPU encoders and the software H.264 / H.265 encoder. Same meaning as the CLI's --video-bitrate |
video_buffer |
string | coded picture buffer for the bitrate rungs, e.g. 500ms (0 for none; default 1s); as the CLI's --video-buffer |
rate_mode |
average (default; abr), cbr (constant) |
how the bitrate rungs are coded: cbr is a constant rate within the buffer (QSV, NVENC, AMF — AV1 included — and the software H.264 / H.265 encoder; not the software AV1 or VP9 encoders, which refuse it by name). A cbr rung with no rate of its own takes video_bitrate, else a default for its codec, size and frame rate; as the CLI's --rate-mode |
video_speed |
draft, standard (default), archive |
encoder effort for every rung, mapped by each encoder onto its own presets (NVENC P5 / P6 / P7; software VP9 from fixed partitions to a searched one; software AV1 its effort, speed 8 / 6 / 4, and motion search range); an encode-policy speed= word wins. The same field in the JSON spec. As the CLI's --video-speed |
audio |
auto (default), opus, mp3, aac, he-aac, he-aacv2, vorbis, ac3, eac3, dts, flac, alac, drop |
audio policy, every encoder rivet's own (opus: MP4 / MOV / WebM, HLS, an Ogg file; mp3: CBR MP3, single-file or audio mode, not HLS; aac / he-aac / he-aacv2: AAC-LC, HE-AAC, HE-AAC v2 — single-file, HLS, an .m4a; vorbis: WebM or an Ogg file, by audio_quality; ac3 / eac3 / dts: up to 5.1, single-file, HLS, an .m4a; flac / alac: lossless) |
audio_quality |
number or string, -1 … 10 |
Vorbis quality (default 5); audio=vorbis only, which takes no audio_bitrate |
audio_bit_depth |
source (default), 16, 24 |
bit depth of flac / alac output |
he_aac |
auto (default), passthrough, core |
an HE-AAC source, which rivet decodes in full (SBR at the full rate, parametric stereo to two channels): auto treats it as any AAC track (passed through where it can be, decoded where the job needs PCM or another codec); passthrough never decodes it; core decodes only its AAC-LC core (half the rate, lower bandwidth) |
audio_decode_deny |
comma list of aac, ac3, alac, dts, eac3, flac, mp2, mp3, opus, pcm, vorbis |
source audio codecs that may not be decoded (default none): a denied track is passed through where the output can carry it, and the job refused, naming the setting, where it needs the PCM; as the CLI's --audio-decode-deny |
metadata_keep |
comma list of location (or location:approximate), capture_time (or capture_time:date), device (or device:all), descriptive, all, none |
the source's identifying metadata to carry into the output (default none: nothing identifying is written). Single-file and audio-mode output; HLS refuses it. As the CLI's --metadata-keep |
flac_compression |
fast, default (default), best |
FLAC compression effort |
audio_container |
auto (default), mp3, flac, mp4, ogg |
the file of an audio-mode output (audio/mpeg, audio/flac, audio/mp4 or audio/ogg in the response); auto follows the codec: .flac for flac, Ogg for opus / vorbis, .m4a for alac, aac, he-aac, he-aacv2, ac3, eac3, dts, else .mp3 |
audio_bitrate |
string | target for transcoded audio, e.g. 240k (MP3: 32k … 320k on the MPEG-1 ladder; AC-3: A/52 Table 5.18's rates, 32k … 640k; E-AC-3 32k … 6144k; DTS: ETSI TS 102 114 Table 5-7's, 32k … 1536k); standard states the default, the codec's rate for the output layout |
audio_channels |
source (default), mono, stereo, 5.1, 7.1 |
output channel layout; downmixes, never upmixes |
audio_stereo_fallback |
bool | HLS: a stereo downmix rendition beside a surround one |
audio_filter |
string | audio filter chain, e.g. channelmap=FL-FL|FR-FR:stereo |
subtitles |
string | all (default) | none | a language list such as eng,deu — text subtitle tracks to carry (tx3g tracks in an MP4, WebVTT renditions in HLS) |
color |
sdr (default), hdr10, hlg, passthrough |
color / tonemap policy |
pixel_format |
auto (default), 8bit, 10bit |
output bit depth (bit_depth in the JSON spec) |
chroma_downsample |
box (default), lanczos |
4:4:4 → 4:2:0 chroma filter for 4:4:4 sources. Query only: the JSON spec has no such field |
seam |
parallel (default), constqp |
multi-GPU single-file chunk-seam quality (serial still parses, as the older spelling of encode=single) |
encode |
all (default), per-rung, single, gpu:N, family:nvidia|amd|intel |
the encode plan — which cards, and how the work is laid across them; wins over gpu. Same words and meaning as the CLI's --encode: every surface interprets through rivet::settings |
decode |
auto (default), whole, fastest, gpu:N, ranges:N |
the decode plan — which card(s), and whether the decode is one pump or split into ranges |
max_fps |
number or string | cap output frame rate; "source" states the default, no cap |
input_fps |
number | the frame rate of a raw video elementary stream input (.h264, .hevc, .obu, .m2v); refused for any other input |
gpu |
integer | pin encode/decode to a GPU index |
filter |
string | video filter chain, e.g. crop=1280:720,hflip (the JSON spec body also accepts a structured list — see Video filters) |
sync |
true/false |
block and return the artifact directly |
hooks |
string | optional server hooks to run on this job, comma-separated (JSON body: "hooks": [...], top level). Required hooks always run; a name the server has not configured is 400. See GET /v1/hooks |
A request that the build can't satisfy (e.g. color=hdr10 on a build with no
10-bit hardware encoder) is rejected 400 at submit time.
A JSON POST /v1/transcode whose input.path or output.path is an
ndi:// URI is a live job (the ndi feature; see ndi.md): an
NDI source recorded to a server file or directory (or relayed to another
ndi://), or a server file played out as an NDI stream. The spec is the
same, with four more keys: duration ("90s", "1h30m"), start_timeout,
idle_timeout and loop.
- It returns
202at once with the job id and itsstopURL; the source is opened and probed by the job, so a source that never appears is the job's failure, not the request's. - A live input needs
output.path.sync: truewith a live input needsspec.duration(it would otherwise never return). GET /v1/jobs/{id}reports itrunningwith per-rung progress; once it ends,livesays what it did —frames,seconds,frame_rate,repeated,dropped_early,dropped_behind, and why itended.- An HLS package is written live (
EVENTplaylists) and served under/v1/jobs/{id}/files/as it grows.
curl -X POST localhost:8080/v1/transcode -H 'Content-Type: application/json' -d '{
"input": { "path": "ndi://STUDIO (Camera 1)" },
"output": { "path": "/rec/cam1.mp4" },
"spec": { "codec": "h264", "rungs": ["1280x720"], "duration": "1h" } }'
# 202 {"job_id":"…","status":"queued","stop":"/v1/jobs/…/stop"}Ends a live job: its output is written and it completes as any job does
(202, then poll GET /v1/jobs/{id}). 409 for a file job, which ends by
itself; 404 for an unknown id.
Job status + per-rung progress + the output list.
curl -s "http://localhost:8080/v1/jobs/$job"{
"job_id": "30a2c394-…",
"mode": "single",
"status": "completed",
"progress": [
{ "rung_index": 0, "label": "720p", "width": 1280, "height": 720,
"status": "completed", "percent": 100.0, "frames_done": 300, "message": null }
],
"artifacts": [
{ "label": "720p", "width": 1280, "height": 720, "frames": 300,
"bytes": 1048576, "url": "/v1/jobs/30a2c394-…/artifacts/720p", "output_path": null }
],
"renditions": [
{ "label": "720p", "requested": { "width": 1280, "height": 720 },
"output": { "width": 1280, "height": 720 }, "fit": "contain", "duplicate_of": null }
],
"master_playlist": null,
"error": null,
"hooks": null
}mode is single or hls (an audio-only job reports single). Each
progress entry's status is pending, running, finalizing, completed
or failed, and its message is why a failed rung failed (null
otherwise). An artifact's url is its download link when the bytes are held
in memory, the job's files/ root for an HLS rendition, and null when it
was written to output.path, which output_path then names. renditions has
one entry per requested rung, in request order: the box asked for, the size
produced, and the rung it merged into when it came out the same as another.
status is queued → running → completed | failed | rejected. With
no job limit (the default) a job is queued only until it starts, at once.
When the operator set one (rivet serve --jobs N or RIVET_SERVER_JOBS), a
job stays queued while N others run, and starts in arrival order; a
?sync=true request waits the same way. On
failure, error carries the message (e.g. "no AV1 encoder available on this
host"). rejected means a hook stopped the job. error names the
hook, the stage and the reason, and a ?sync=true request gets 422.
hooks is the job's hook report, readable while the job runs and after it
ends whatever the outcome: job_id, rejected, rejection, and records,
one per hook per event (hook, kind, stage, subject, verdict,
reason, annotations, error, elapsed_ms, background). It is null
when the job runs no hooks.
The hooks this server runs (configured by the program embedding the server,
see hooks.md): { "hooks": [ { "name", "kind", "description", "stages", "mode", "on_error", "required", "frames", "artifact_kinds" } ] }. kind is where the hook hooks in: source, probe,
decoded-frame, encoder-frame, still, artifact, completed, failed,
or event for a general hook at the stages it lists. stages lists
them; mode is blocking or background; on_error is continue or
reject; frames is the frame sampling (every_frames, every_seconds,
max_frames) for a frame hook and null otherwise; artifact_kinds lists
the artifacts an artifact hook sees and is null otherwise. A job runs every
required hook, plus the optional ones it names with hooks=.
Download a single-file rung's MP4 (Content-Type: video/mp4), or an
audio-only job's file (audio/mpeg, audio/flac or audio/mp4). Only
artifacts held in memory are served; one written to output.path is 404.
curl -so 720p.mp4 "http://localhost:8080/v1/jobs/$job/artifacts/720p"For HLS jobs, fetch a file from the output tree — the playlist and segments:
curl -s "http://localhost:8080/v1/jobs/$job/files/master.m3u8"
curl -so seg.m4s "http://localhost:8080/v1/jobs/$job/files/video/720p/seg-00001.m4s"Served with the right content type (application/vnd.apple.mpegurl,
video/iso.segment, video/mp4; anything else, WebVTT included, as
application/octet-stream). A path with a .. or empty component is
rejected 400.
Async (submit, poll, download):
curl -s http://localhost:8080/v1/health
job=$(curl -s --data-binary @input.mkv \
"http://localhost:8080/v1/transcode?mode=single&crf=28" | jq -r .job_id)
# poll until status == completed
curl -s "http://localhost:8080/v1/jobs/$job" | jq .status
curl -so out.mp4 "http://localhost:8080/v1/jobs/$job/artifacts/720p"Synchronous (single-file, single rung — the file comes back; with several
rungs the response is the job status JSON, whose artifacts[].url fetch each):
curl -s --data-binary @input.mkv \
"http://localhost:8080/v1/transcode?sync=true" -o out.mp4HLS ladder:
job=$(curl -s --data-binary @input.mkv \
"http://localhost:8080/v1/transcode?mode=hls&ladder=true&segment_seconds=4" \
| jq -r .job_id)
# after completion:
curl -s "http://localhost:8080/v1/jobs/$job/files/master.m3u8"JSON errors with the appropriate HTTP status:
{ "error": "h264 at 10 bits (color=Hdr10, bit_depth=Auto) cannot be encoded: this build encodes h264 with nvenc (8-bit SDR). h264 at 10 bits needs the software tier (build with `h26x-fallback`) …" }400 Bad Request— empty body, non-media body, invalid JSON body, bad query params, a spec the build can't produce, a hook name the server has not configured, an input/output path that is missing or escapesRIVET_FILE_ROOT, or afiles/path with...404 Not Found— unknown/malformed job id, missing artifact or file.422 Unprocessable Entity— a job a hook rejected undersync.500 Internal Server Error— a job that failed undersync.
- In-memory registry. Jobs and completed single-file artifacts are held in
RAM until the process exits — this is a sidecar/worker, not a public CDN. For
durable output, layer an uploader on top by watching
RungStatus::Completedfrom aProgressSink(object storage, a status queue, …) and run the engine via the library API directly. - GPU-only encode by default. A host with no encode silicon for the chosen
codec and no software fallback (
av1-sw-fallbackfor AV1,h26x-fallbackfor H.264 / H.265) will accept jobs and report themfailedwith the encoder error. Check/v1/healthoutput_capsfirst. - Pair with an encode feature.
--features serveralone has no encoder; build--features server,nvidia(oramd/qsv, orav1-sw-fallback/h26x-fallbackfor software AV1 / H.264 / H.265) for your target.