dyndo
Dynamic media packaging for adaptive streaming, in Rust.
dyndo turns your existing CMAF files into an adaptive-streaming service
without repackaging or duplicating a single byte of media. You index your
sources once into a small JSON descriptor; the server then generates DASH and
HLS manifests and serves CMAF segments on the fly, straight from the original
files via HTTP byte-range reads.
dyndois in early development. Both DASH and HLS are implemented, served from the same CMAF sources.
The idea in one picture
CMAF sources asset.json dyndo-server
(fragmented MP4 dyndo (thin │
+ global sidx) ──index──▶ descriptor) ────────▶ ┌───┴────────────────────┐
│ │ GET …/dash/index.mpd │
└───────────── byte-range reads ───────────────▶│ GET …/hls/index.m3u8 │
│ GET …/<repr>/init.mp4 │
│ GET …/<repr>/<t>.m4s │
└────────────────────────┘
- Index your CMAF files once with the
dyndoCLI to produceasset.json. - Serve that descriptor with
dyndo-server. At request time it parses each source’s header, derives the segment index, and answers manifest and segment requests with ranged reads from the original media.
Because the descriptor stores only per-track metadata and a source path — no
segment list, no byte offsets — a single asset.json is a few hundred bytes,
and serving an 800 MB source reads the same ~10 KB header region as an 8 MB one.
See The thin-pointer approach for why.
The two tools
dyndo is two programs, and you get each the easy way — no Rust toolchain required:
| Tool | What it does | How to get it |
|---|---|---|
dyndo (CLI) | Index CMAF and WebVTT sources into asset.json and render manifests offline. | The one-line installer. |
dyndo-server | Generate DASH/HLS manifests and serve CMAF segments on the fly. | The matvp91/dyndo-server image on Docker Hub. |
This book covers both programs and the asset.json descriptor that connects
them: you produce a descriptor with the CLI, and the server reads it. If neither
the installer nor Docker fits your platform, you can also
build from source.
Quick install
Install the dyndo CLI (macOS and Linux):
curl -fsSL https://matvp91.github.io/dyndo/install.sh | bash
See Install the CLI for details. For the server, pull the Docker image — see Deploy with Docker.
How to read this book
This documentation follows the Diátaxis framework. Pick the section that matches what you need right now:
- Tutorial — I’m new here. A single guided lesson that takes you from nothing to a playing stream.
- How-to guides — I have a task to do. Focused recipes: index sources, add subtitles, label tracks with roles, run the server, serve from S3, deploy with Docker.
- Reference — I need to look something up. Exact, exhaustive descriptions of every command, route, config key, descriptor field, and track role.
- Explanation — I want to understand why. The design ideas behind dyndo: the thin pointer, bounded-memory parsing, and one source serving two protocols.
Supported codecs
Codec parameters are read from the source and emitted as RFC 6381 strings.
| Media | Codec | Sample entry |
|---|---|---|
| Video | AVC / H.264 | avc1 |
| Video | HEVC / H.265 | hvc1, hev1 |
| Video | AV1 | av01 |
| Audio | AAC | mp4a |
| Audio | Dolby Digital (AC-3) | ac-3 |
| Audio | Dolby Digital Plus (E-AC-3) | ec-3 |
| Text | WebVTT in ISO-BMFF | wvtt |
Raw WebVTT (.vtt) files are also accepted as text-track sources, with no
packaging step — see Add a subtitle track.
Install the CLI
In this short lesson you’ll install the prebuilt dyndo CLI and confirm it
runs — no Rust toolchain needed. Set aside two minutes.
Prebuilt binaries cover macOS (Apple Silicon and Intel) and Linux (x86_64). On any other platform, build from source instead.
Step 1: Run the installer
curl -fsSL https://matvp91.github.io/dyndo/install.sh | bash
The script detects your platform, downloads the latest release from GitHub,
verifies its checksum, and installs the dyndo binary into ~/.dyndo/bin.
If that directory isn’t on your PATH yet, it appends one line (marked
# dyndo) to your shell’s rc file — ~/.zshrc, ~/.bashrc, or fish’s
config.fish — and says so.
Step 2: Verify it runs
Open a new terminal, then:
dyndo --version
You should see the name and version, e.g. dyndo x.y.z. That’s it — the CLI
is installed.
Pinning a version
The installer takes an optional version, with or without the leading v:
curl -fsSL https://matvp91.github.io/dyndo/install.sh | bash -s <version>
Setting DYNDO_VERSION=<version> does the same. Available versions are
listed on the releases page.
Uninstalling
Remove the install directory, and the # dyndo line from your shell’s rc
file if you like:
rm -rf ~/.dyndo
Where to next?
Follow Getting started to create CMAF sources, index
them, and play a stream. It runs dyndo-server with Docker, since this
installer ships only the dyndo CLI — for the server on its own, see
Deploy with Docker.
Getting started
This tutorial takes you from an empty directory to a playing adaptive stream
served by dyndo. Along the way you’ll install the CLI, create a pair of CMAF
sources, index them into an asset.json, and start the server as a container.
You don’t need to know anything about CMAF, DASH, or HLS to follow along — we’ll create everything from scratch. By the end you’ll have a running server answering DASH and HLS requests, and you’ll understand the two-step index-then-serve workflow that everything else in dyndo builds on.
Set aside about 15 minutes.
What you’ll need
- The
dyndoCLI — you’ll install it in step 1, no Rust toolchain required. ffmpeg, used only to create the sample media in step 2. If you already have CMAF files, you can skip that step.- Docker, to run the server in step 4.
Work in a fresh directory of your choice; every command below is run from there.
Step 1: Install the CLI
Install the prebuilt dyndo CLI with the one-line installer:
curl -fsSL https://matvp91.github.io/dyndo/install.sh | bash
Open a new terminal so the updated PATH takes effect, then check it works — it
prints its name and version:
dyndo --version
Prefer not to use the installer, or on a platform it doesn’t cover? See Install the CLI for options, or Build from source.
Step 2: Create two CMAF sources
dyndo serves CMAF: fragmented MP4 files, one media track each, carrying a
single global sidx segment index. Let’s make one video track and one audio
track.
First, generate a 10-second test clip with a video and an audio stream:
ffmpeg -f lavfi -i "testsrc=size=1280x720:rate=25:duration=10" \
-f lavfi -i "sine=frequency=440:duration=10" \
-c:v libx264 -profile:v high -pix_fmt yuv420p -g 50 -keyint_min 50 \
-c:a aac -b:a 128k -ar 48000 -ac 2 \
source.mp4
Now repackage each track into its own CMAF file, into an assets/ directory —
that’s the directory you’ll hand to the server in step 4. The +global_sidx
flag is the important one: it writes a single segment index covering the
whole file, which is what dyndo reads.
mkdir -p assets
# Video track -> assets/video.mp4
ffmpeg -i source.mp4 -map 0:v:0 -c copy \
-movflags +frag_keyframe+empty_moov+separate_moof+default_base_moof+global_sidx \
assets/video.mp4
# Audio track -> assets/audio.mp4
ffmpeg -i source.mp4 -map 0:a:0 -c copy \
-movflags +frag_keyframe+empty_moov+separate_moof+default_base_moof+global_sidx \
assets/audio.mp4
You now have two CMAF sources under assets/.
Step 3: Index the sources
Turn your two files into an asset.json descriptor. Each positional argument
adds one track; paths are resolved relative to the output descriptor’s
directory, so from your working directory they’re just the file names inside
assets/. An input can also carry key=value parameters after the path —
here we tag the audio track’s language:
dyndo index video.mp4 audio.mp4,language=eng -o assets/asset.json
wrote assets/asset.json (2 tracks)
Take a look at what it wrote:
cat assets/asset.json
{
"tracks": [
{
"id": "video_720_avc1_126233",
"path": "video.mp4",
"type": "video",
"width": 1280,
"height": 720,
"fourcc": "avc1"
},
{
"id": "audio_und_2_mp4a_131171",
"path": "audio.mp4",
"type": "audio",
"sample_rate": 48000,
"channels": 2,
"language": "eng",
"fourcc": "mp4a"
}
]
}
That’s the whole descriptor: per-track metadata and a source path, nothing more.
Notice there’s no segment list and no byte offsets — the server re-derives those
from each source at request time. (Your id numbers may differ slightly; they
include the measured bitrate, which depends on your exact encode. And the audio
id says und even though we set language=eng: ids are minted from what the
file itself declares — our ffmpeg test clip has no language — and then never
change, so URLs stay stable however you relabel a track.)
Step 4: Start the server
Run dyndo-server from Docker Hub, mounting your assets/ directory into the
container:
docker run --rm -p 8080:8080 \
-e DYNDO_FS__ROOT=/assets \
-v "$PWD/assets:/assets:ro" \
matvp91/dyndo-server
dyndo-server listening on http://0.0.0.0:8080
-v "$PWD/assets:/assets:ro"mounts your descriptor and its CMAF sources into the container, read-only.-e DYNDO_FS__ROOT=/assetspoints the server’s storage root at that mount.-p 8080:8080publishes the port to your host.
The server exposes each descriptor it finds as both a DASH and an HLS stream. Leave it running and open a second terminal for the next step. (For everything Docker can do here — pinning a version, using a config file, serving from S3 — see Deploy with Docker.)
Step 5: Play the stream
Your descriptor is at assets/asset.json, and the storage root is that
assets/ directory, so its path relative to the root is just asset.json.
First, confirm the DASH manifest is being served:
curl http://localhost:8080/asset.json/dash/index.mpd
You should see an XML document beginning with <MPD …> that lists a video and
an audio AdaptationSet. The HLS multivariant playlist is served from the same
asset:
curl http://localhost:8080/asset.json/hls/index.m3u8
#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,URI="audio_und_2_mp4a_131171.m3u8",GROUP-ID="mp4a",LANGUAGE="eng",NAME="eng",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2"
#EXT-X-STREAM-INF:BANDWIDTH=257404,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=25.000,AUDIO="mp4a"
video_720_avc1_126233.m3u8
#EXT-X-INDEPENDENT-SEGMENTS
That’s a working stream. To watch it, point a player at the manifest URL. VLC can open either protocol directly:
vlc http://localhost:8080/asset.json/dash/index.mpd # or the .m3u8 for HLS
You’ll see the ffmpeg test pattern play and hear the tone. dyndo is serving the
manifest and every segment live, reading ranges out of assets/video.mp4 and
assets/audio.mp4 as the player requests them.
When you’re done, stop the server with Ctrl-C.
What you did
In a few minutes you:
- installed the
dyndoCLI; - produced two CMAF sources;
- indexed them into a tiny
asset.jsondescriptor; and - served that descriptor as a live DASH and HLS stream with Docker.
That index-then-serve split is the core of dyndo: index once, serve many protocols, and never duplicate your media.
Where to next
- Add subtitles to the stream you just built: Add a subtitle track.
- Label audio and subtitle tracks so players present them correctly: Label tracks with roles.
- Everything Docker can do — pin a version, mount a config file, run in production: Deploy with Docker.
- Serve your media from object storage instead of local disk: Serve media from S3.
- Understand what just happened under the hood: The thin-pointer approach.
- Look up every command and option: dyndo CLI reference.
Index your CMAF sources
This guide shows how to build an asset.json descriptor from a set of media
files with dyndo index. You do this once per asset; the descriptor is what the
server (or the offline manifest commands) reads afterwards.
Before you start
index accepts two kinds of input, selected by file extension:
.mp4— a CMAF track: a fragmented MP4 containing amoovwith exactly one track, a single globalsidxsegment index, and a supported codec..vtt— a raw WebVTT subtitle file (see Add a subtitle track).
Any violation aborts the run — there are no silent fallbacks and no skip-and-continue. If you need to produce conforming CMAF files, see the ffmpeg recipe in the Getting started tutorial.
Index one track per input
Pass each source as a positional argument. Every file becomes one track — one video rendition, one audio rendition, and so on:
dyndo index \
video_1080.mp4 \
video_720.mp4 \
audio_en.mp4 \
-o asset.json
wrote asset.json (3 tracks)
The -o path defaults to asset.json in the current directory; pass it
explicitly to write elsewhere.
Set a language or role
An input may carry per-track parameters after the path, as comma-separated
key=value fields — language (an ISO-639-2 code) and role (the track’s
purpose). Both apply to audio and text tracks only:
dyndo index \
video.mp4 \
audio_nl.mp4,language=nld,role=main \
audio_fr.mp4,language=fra,role=dub \
-o asset.json
language overrides the code probed from the file; role is never probed, so
this is the only way to set it apart from editing the JSON. Valid roles are,
for audio, main, alternate, commentary, dub, description,
enhanced-audio-intelligibility; for text, subtitle, caption,
forced-subtitle. A video input takes neither field, an unknown field is
rejected, and a role that does not apply to the track’s type (e.g. subtitle
on audio) is rejected — the run aborts with a message.
For what each role does to the generated manifests — which rendition a player defaults to, what it auto-selects, and the accessibility signalling — see Label tracks with roles.
Add to or update an existing descriptor
Running index against an asset.json that already exists merges into it
rather than overwriting, keyed by each input’s source path:
- a new path is probed from its file and appended;
- a path already in the descriptor keeps its entry exactly as it stands —
the file’s metadata is not re-probed, so anything you’ve hand-edited in the
JSON survives. The only thing a re-index changes is what you explicitly ask
for with
language=/role=overrides.
# start with the video
dyndo index video.mp4 -o asset.json # wrote asset.json (1 tracks)
# append an audio track
dyndo index audio.mp4 -o asset.json # wrote asset.json (2 tracks)
# set a role on that same audio track — still two tracks, nothing else changes
dyndo index audio.mp4,role=main -o asset.json # wrote asset.json (2 tracks)
Two consequences of this merge model are worth knowing:
- Updating a descriptor re-opens every source already listed in it, so all indexed files must still exist — a re-index fails if one has gone missing.
- If a source file’s content changed,
indexwon’t notice: remove the track’s entry from the JSON (or delete the descriptor) and index the file afresh. Likewise, renaming a source on disk and indexing the new name appends a second entry — remove the stale one by hand.
Understand how paths resolve
Input paths are relative to the output descriptor’s directory, not to your
shell’s working directory. This keeps a descriptor portable: path values in
asset.json stay valid as long as the sources sit in the same place relative to
it.
For example, writing the descriptor into a subdirectory:
dyndo index video.mp4 audio.mp4 -o out/asset.json
resolves the inputs as out/video.mp4 and out/audio.mp4, and records
"path": "video.mp4" and "path": "audio.mp4" in the descriptor.
The root that all of this resolves against is dyndo’s storage root, which for
the CLI is the current directory. Override it with the OPENDAL_FS_ROOT
environment variable:
OPENDAL_FS_ROOT=/srv/media dyndo index video.mp4 -o asset.json
Inspect the result
The descriptor is small, human-readable JSON — safe to open, diff, and even hand-edit:
{
"tracks": [
{
"id": "video_1080_avc1_4807228",
"path": "video_1080.mp4",
"type": "video",
"width": 1920,
"height": 1080,
"fourcc": "avc1"
}
]
}
Each track’s id is derived from its properties at index time (for video,
video_<height>_<fourcc>_<bitrate>) and then pinned — later edits never
change it. The server uses these ids as the representation names in every
manifest and segment URL. For the full field list, see the
asset.json descriptor reference.
Next steps
- Add subtitles to the descriptor: Add a subtitle track.
- Control how players present each track: Label tracks with roles.
- Serve the descriptor: Run and configure the server.
- Render a manifest without the server: Generate manifests without the server.
Add a subtitle track
This guide shows how to add a WebVTT subtitle track to an existing asset.
Subtitles are the one track type you don’t package ahead of time: you hand
dyndo index the raw .vtt file itself, and
serving works from that source directly — chunking the raw WebVTT, or packaging
it as CMAF wvtt, on the fly at request time. Your .vtt stays the single
source of truth.
Text-track serving is the part of dyndo currently under construction: CMAF
wvtttracks are advertised in DASH manifests today, while manifest advertisement for raw.vtttracks and HLS subtitle renditions are still being wired up. Indexing works as described below either way, and descriptors you build now will be served as those pieces land.
Before you start
You need:
- an
asset.json(see Index your CMAF sources); and - a WebVTT file (
.vtt).
Add the subtitle
Index the .vtt like any other source, with a language:
dyndo index subtitles_nl.vtt,language=nld -o asset.json
wrote asset.json (3 tracks)
Your descriptor now carries the text track alongside the others, pointing
straight at the .vtt:
{
"id": "text_und",
"path": "subtitles_nl.vtt",
"type": "text",
"language": "nld"
}
The language value is an ISO 639-2
three-letter code (eng, nld, fra, …). A WebVTT file declares no language
of its own, so set it here — if you omit it, the track’s language is und
(undetermined).
Add subtitles in several languages
Each .vtt file becomes one track; index them together or in separate runs
against the same descriptor:
dyndo index \
subtitles_nl.vtt,language=nld \
subtitles_en.vtt,language=eng \
-o asset.json
Re-indexing the same .vtt path never duplicates the track — index updates
the existing entry in place.
Give the subtitle a role
By default a text track is presented as a plain subtitle. To mark it as
closed captions (SDH) or a forced-narrative track, re-index it with a role —
this updates the entry in place and changes nothing else:
dyndo index subtitles_en.vtt,role=caption -o asset.json
Valid text roles are subtitle, caption, and forced-subtitle. Each changes
how the track is signalled in the generated manifests — see
Label tracks with roles.
Already-packaged subtitles (CMAF wvtt)
If a packager already gave you WebVTT in ISO-BMFF — a CMAF wvtt track — index
it like any other CMAF source. It is a regular text track (these are the ones
DASH manifests advertise today):
dyndo index text_wvtt_eng.mp4,language=eng -o asset.json
{
"id": "text_eng_wvtt_586",
"path": "text_wvtt_eng.mp4",
"type": "text",
"language": "eng",
"fourcc": "wvtt"
}
Correct a subtitle’s language after the fact
The language stored in asset.json is authoritative. To relabel a track,
either re-index it with a new language= override or edit the field in the
JSON directly — the manifests follow without any repackaging. The track’s id
never changes with it: ids are pinned at index time so segment URLs stay
stable (see Representation ids).
Next steps
- Mark subtitles as captions or forced narrative: Label tracks with roles.
- Serve the asset: Run and configure the server.
- The text-track fields in detail: asset.json descriptor.
Label tracks with roles
A track’s role is its author-declared purpose — main audio, director’s commentary, audio description, closed captions, forced narrative subtitles, and so on. dyndo records the role in the descriptor and renders it into both the DASH and HLS manifests, so players present the track correctly: which audio a viewer hears by default, which subtitles auto-enable, and which renditions are flagged for accessibility.
Roles apply to audio and text tracks only — never video.
Set a role while indexing
A role is never probed from the media; you declare it. Add role=<role> to a
track descriptor when you index it:
dyndo index \
video.mp4 \
audio_en.mp4,language=eng,role=main \
audio_en_commentary.mp4,language=eng,role=commentary \
-o asset.json
To set a role on a track that’s already in the descriptor, re-index it — index
merges by source path, so this updates the entry in place and changes nothing
else about it:
dyndo index audio_en_commentary.mp4,role=commentary -o asset.json
You can also hand-edit the role field in asset.json directly; it takes the
same values.
Audio roles
| Role | Use it for | Effect on players |
|---|---|---|
main | The primary audio. | Becomes the default audio rendition. |
alternate | An alternate mix of the main audio. | Auto-selectable (e.g. to match the viewer’s language). |
dub | A dubbed rendition in another language. | Auto-selectable (e.g. to match the viewer’s language). |
commentary | Commentary, e.g. director’s. | Opt-in: never auto-selected, the viewer chooses it. |
description | Audio description for blind / low-vision viewers. | Opt-in, and flagged as accessible audio description. |
enhanced-audio-intelligibility | Dialogue enhanced for intelligibility. | Opt-in, and flagged as an accessibility rendition. |
The default audio rendition is the first track you mark main; if you mark
none, it’s the first audio track. Mark exactly one track main to control what
plays by default.
Text (subtitle) roles
Text-track manifest output is still being completed — today a text role shows up in DASH (for CMAF
wvttsources); HLS subtitle renditions land next. See Track roles for the exact per-protocol state.
| Role | Use it for | Effect on players |
|---|---|---|
subtitle | Translation subtitles (dialogue only). | Off by default; the viewer turns them on. This is also the default when no role is set. |
caption | SDH / closed captions (dialogue plus non-dialogue sound). | Off by default; flagged with the SDH accessibility characteristics. |
forced-subtitle | Forced narrative (foreign dialogue, on-screen text). | Auto-enabled and marked FORCED, so it shows even when subtitles are otherwise off. |
Examples
A main track plus a commentary track — the viewer hears the main audio by default and can switch to commentary:
dyndo index \
video.mp4 \
audio_en.mp4,language=eng,role=main \
audio_en_comm.mp4,language=eng,role=commentary \
-o asset.json
An audio-description track for accessibility:
dyndo index audio_en_ad.mp4,language=eng,role=description -o asset.json
Forced subtitles that display automatically for foreign-language dialogue —
set the role directly on the .vtt you index:
dyndo index forced_en.vtt,language=eng,role=forced-subtitle -o asset.json
What gets rejected
index validates roles as it reads each descriptor and aborts the whole run on:
- a
role(orlanguage) on a video input; - a
rolethat doesn’t apply to the track’s media type — e.g.subtitleon audio, ormainon text; or - an unknown role value.
Next steps
- The exact DASH and HLS output for every role: Track roles reference.
- How
rolesits in the descriptor: asset.json descriptor. - Where roles are set: Index your CMAF sources.
Generate manifests without the server
The server renders manifests on the fly, but the CLI can also write them to
disk from an asset.json. This is useful for inspecting the exact XML or
playlist a descriptor produces, diffing manifests across changes, or validating
output without starting a server.
These commands only render manifests — they don’t serve media. Players still need the CMAF segments, which in production the server provides.
Render a DASH manifest
dyndo dash -i asset.json -o stream.mpd
wrote stream.mpd
The output is a static MPD describing every representation in the asset:
<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:isoff-live:2011" type="static" mediaPresentationDuration="PT22M50.325S" minBufferTime="PT1.962S" …>
<Period id="0" start="PT0S">
<AdaptationSet id="0" contentType="video" segmentAlignment="true" mimeType="video/mp4" startWithSAP="1">
<Representation id="video_1080_avc1_4807228" bandwidth="4807228" width="1920" height="1080" frameRate="25" codecs="avc1.640028">
<SegmentTemplate media="$RepresentationID$/$Time$.m4s" initialization="$RepresentationID$/init.mp4" timescale="90000" presentationTimeOffset="0">
<SegmentTimeline>
<S t="0" d="172800" r="355"/>
…
</SegmentTimeline>
</SegmentTemplate>
</Representation>
</AdaptationSet>
…
</Period>
</MPD>
Compact the manifest
By default each Representation carries its own SegmentTemplate. The
-c/--compact flag hoists a SegmentTemplate shared by all representations up
to the AdaptationSet level, producing a smaller manifest:
dyndo dash -i asset.json -o stream.mpd --compact
In compact output the SegmentTemplate precedes the Representation elements
within each set:
<AdaptationSet id="0" contentType="video" segmentAlignment="true" mimeType="video/mp4" startWithSAP="1">
<SegmentTemplate media="$RepresentationID$/$Time$.m4s" initialization="$RepresentationID$/init.mp4" timescale="90000" presentationTimeOffset="0">
<SegmentTimeline>
<S t="0" d="172800" r="355"/>
…
</SegmentTimeline>
</SegmentTemplate>
<Representation id="video_1080_avc1_4807228" bandwidth="4807228" width="1920" height="1080" frameRate="25" codecs="avc1.640028"/>
</AdaptationSet>
The server always renders DASH in compact form. Use
--compacthere when you want the CLI output to match what the server would serve.
Render HLS playlists
HLS is a set of files — a multivariant playlist plus one media playlist per
advertised track — so dyndo hls writes to a directory rather than a
single file:
dyndo hls -i asset.json -o hls
wrote hls/ (1 master + 3 media)
The directory contains the multivariant playlist and one media playlist per
video and audio track, named by track id (text tracks are not yet advertised
in HLS — see the dyndo hls reference):
hls/
├── index.m3u8 # multivariant (master) playlist
├── video_1080_avc1_4807228.m3u8 # one media playlist per track
├── video_720_avc1_3205265.m3u8
└── audio_nld_2_mp4a_196918.m3u8
Each media playlist references the segments by the same relative URLs the server
uses (<id>/init.mp4, <id>/<time>.m4s):
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:2
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="video_1080_avc1_4807228/init.mp4"
#EXTINF:1.92,
video_1080_avc1_4807228/0.m4s
#EXTINF:1.92,
video_1080_avc1_4807228/172800.m4s
…
#EXT-X-ENDLIST
Next steps
- Serve manifests dynamically instead: Run and configure the server.
- Full options:
dyndo dashanddyndo hls. - Why the segment URLs are identical across protocols: One source, two protocols.
Run and configure the server
This guide covers how dyndo-server is configured and how it behaves once
running. For the container recipes themselves — pinning versions, mounting a
config file, building the image — see
Deploy with Docker. For the complete schema and
precedence rules, see the
Configuration reference.
Start the server
The server ships as the
matvp91/dyndo-server image.
The one setting you must supply is the storage root — the directory (or
bucket) your descriptors and media live in. There is no built-in default; if the
selected backend has no root, the server exits at startup.
docker run --rm -p 8080:8080 \
-e DYNDO_FS__ROOT=/assets \
-v "$PWD/assets:/assets:ro" \
matvp91/dyndo-server
dyndo-server listening on http://0.0.0.0:8080
Here the fs backend (the default) reads from /assets, which is your local
./assets mounted into the container. Running from a source build instead? The
dyndo-server binary reads the same configuration — see
Build from source.
Request a stream
Request a stream at /<asset>/<protocol>/<resource>, where <asset> is the
descriptor’s path relative to the storage root:
http://localhost:8080/asset.json/dash/index.mpd # DASH
http://localhost:8080/asset.json/hls/index.m3u8 # HLS
Nested descriptors work too: a descriptor at assets/movies/big/asset.json
(with storage root assets/) is served at
/movies/big/asset.json/dash/index.mpd. See the
HTTP routes reference for the full route table.
Configure with a file or environment variables
The server layers three sources, each overriding the one before it: built-in
defaults, then a config.yaml, then DYNDO_-prefixed environment variables.
A config.yaml looks like this:
store: fs
server:
host: 0.0.0.0
port: 8080
fs:
root: /assets
storeselects the storage backend:fs(local disk) ors3.server.host/server.portset the listen address.- The
fs:section configures the local-filesystem backend; itsrootis the directory descriptors and media are read from.
To use a file, mount it and point the server at it with DYNDO_CONFIG (see
Deploy with Docker).
Every setting also maps to a DYNDO_-prefixed environment variable. Nested keys
use a double underscore (__) as the separator, so single underscores
inside a field name survive:
docker run --rm -p 8080:8080 \
-e DYNDO_SERVER__PORT=9000 \
-e DYNDO_FS__ROOT=/assets \
-v "$PWD/assets:/assets:ro" \
matvp91/dyndo-server
Environment variables take precedence over config.yaml, which in turn
overrides the built-in defaults — so you can bake a config.yaml into an image
and override just what differs per environment.
Point at a specific config file
By default the server looks for config.yaml in its working directory. Set
DYNDO_CONFIG to load a different path:
-e DYNDO_CONFIG=/etc/dyndo/prod.yaml
When DYNDO_CONFIG is set, the named file must exist or the server exits
with an error. Without it, a missing config.yaml is fine — the server falls
back to defaults and environment variables.
Health checks
The server answers GET /health with 200 OK. Use it as a container or
load-balancer liveness probe; it never collides with a stream route. See
Deploy with Docker for wiring it into an
orchestrator.
Serving to browser players
The server sends permissive CORS headers (any origin, any method), so a browser-based player can load a manifest during development without a proxy.
Next steps
- Container recipes and production tips: Deploy with Docker.
- Serve from object storage: Serve media from S3.
- Full configuration schema and precedence: Configuration reference.
- Every route and status code: HTTP routes reference.
Serve media from S3
All of dyndo’s I/O goes through OpenDAL, so the
server can read descriptors and media from Amazon S3 (or an S3-compatible store)
instead of the local filesystem. Nothing about your assets changes — the same
asset.json and CMAF files work unmodified, because paths inside a descriptor
are relative and backend-agnostic.
Backed by S3 the server is stateless: there’s no volume to mount, and all configuration comes through environment variables.
Run against S3
Select the s3 store and give it a bucket, region, endpoint, and credentials:
docker run --rm -p 8080:8080 \
-e DYNDO_STORE=s3 \
-e DYNDO_S3__BUCKET=my-assets \
-e DYNDO_S3__REGION=eu-west-1 \
-e DYNDO_S3__ENDPOINT=https://s3.eu-west-1.amazonaws.com \
-e AWS_ACCESS_KEY_ID=AKIA... \
-e AWS_SECRET_ACCESS_KEY=... \
matvp91/dyndo-server
DYNDO_S3__BUCKETis required — the server fails to start without it.DYNDO_S3__REGIONandDYNDO_S3__ENDPOINTidentify the S3 service.- Every S3 setting maps to a
DYNDO_S3__*variable (double underscore between segments, single underscores preserved within a name), soDYNDO_S3__ACCESS_KEY_IDsetss3.access_key_id.
With store: s3, an asset at key asset.json is served exactly as it is from
local disk:
http://localhost:8080/asset.json/dash/index.mpd
Set DYNDO_S3__ROOT to prepend a key prefix if your assets live under a
subdirectory of the bucket.
The same, from a config file
If you’d rather keep settings in a file, the equivalent config.yaml is:
store: s3
server:
host: 0.0.0.0
port: 8080
s3:
bucket: my-assets
region: eu-west-1
endpoint: https://s3.eu-west-1.amazonaws.com
root: /
Mount it and point the server at it with DYNDO_CONFIG — see
Deploy with Docker.
Credentials
The S3 backend reads the standard AWS environment variables
(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), so credentials never have to live
in config.yaml. You can also supply them through dyndo’s own configuration
(DYNDO_S3__ACCESS_KEY_ID, DYNDO_S3__SECRET_ACCESS_KEY).
Prefer the AWS environment variables or a secrets manager for credentials in production; keep any checked-in
config.yamlfree of secrets.
Verify the backend
If the selected store is misconfigured — an s3 store with no bucket, or an
fs store with no root — the server fails at startup while building the
storage operator, rather than returning errors per request. A clean
listening on … line means the backend is wired up correctly.
Next steps
- All S3 keys and how they map to environment variables: Configuration reference.
- General server operation: Run and configure the server.
- Container recipes: Deploy with Docker.
Deploy with Docker
Every release of dyndo-server is published to Docker Hub as
matvp91/dyndo-server, so you
can run it anywhere Docker runs — no Rust toolchain required. The image is
multi-arch (amd64 and arm64), and one image serves from either storage backend;
you pick the backend at runtime with configuration.
Quick start: run it locally
This is the fastest way to get dyndo running. You need a directory containing
an asset.json and the CMAF sources it points at — if you don’t have one yet,
the Getting started tutorial shows how to
create it. Then one command starts the server:
docker run --rm -p 8080:8080 \
-e DYNDO_FS__ROOT=/assets \
-v "$PWD/assets:/assets:ro" \
matvp91/dyndo-server
-p 8080:8080publishes the port. The server binds0.0.0.0inside the container, so it is reachable from the host with no extra configuration.-v "$PWD/assets:/assets:ro"mounts your assets read-only — the server only reads them.-e DYNDO_FS__ROOT=/assetspoints the filesystem backend at the mount.fsis the default store, so its root is the only setting you must supply.
Then request a stream just as you would from a local server:
http://localhost:8080/asset.json/dash/index.mpd # DASH
http://localhost:8080/asset.json/hls/index.m3u8 # HLS
Pin a version
Releases are tagged three ways on Docker Hub: the full version
(:<major>.<minor>.<patch>), the minor line (:<major>.<minor>), and
:latest. Pick a version from the
tags page and pin the
full version in production so a new release never changes what you run:
docker run --rm -p 8080:8080 -e DYNDO_FS__ROOT=/assets \
-v "$PWD/assets:/assets:ro" matvp91/dyndo-server:<version>
Serve from S3 instead
Backed by S3 the container is stateless — no volume, all configuration through environment variables:
docker run --rm -p 8080:8080 \
-e DYNDO_STORE=s3 \
-e DYNDO_S3__BUCKET=my-assets \
-e DYNDO_S3__REGION=eu-west-1 \
-e DYNDO_S3__ENDPOINT=https://s3.eu-west-1.amazonaws.com \
-e AWS_ACCESS_KEY_ID=AKIA... \
-e AWS_SECRET_ACCESS_KEY=... \
matvp91/dyndo-server
The values above are placeholders — substitute your own bucket, region, endpoint, and credentials. See Serve media from S3 for how each setting maps, and prefer a secrets manager over inline credentials in production.
Use a config file instead of environment variables
To keep settings in a file, mount a config.yaml and point the server at it
with DYNDO_CONFIG:
docker run --rm -p 8080:8080 \
-v "$PWD/config.yaml:/etc/dyndo/config.yaml:ro" \
-v "$PWD/assets:/assets:ro" \
-e DYNDO_CONFIG=/etc/dyndo/config.yaml \
matvp91/dyndo-server
Environment variables still override the file, so you can bake defaults into
config.yaml and override per environment.
Health checks
The server answers GET /health with 200 OK. Point a Kubernetes liveness or
readiness probe — or an external load balancer — at it:
# Kubernetes pod spec
livenessProbe:
httpGet:
path: /health
port: 8080
The runtime image is deliberately minimal — just the binary and CA
certificates, with no HTTP client — so prefer an out-of-container probe
like the above over a HEALTHCHECK that would need curl inside the
container.
Build the image yourself
The repository ships a Dockerfile, so you can build from source instead of
pulling:
docker build -t dyndo-server .
docker run --rm -p 8080:8080 -e DYNDO_FS__ROOT=/assets \
-v "$PWD/assets:/assets:ro" dyndo-server
It is a multi-stage build: a Rust stage compiles dyndo-server, and a slim
Debian runtime carries just the binary and CA certificates (needed for S3 over
TLS). The container runs as an unprivileged user.
Next steps
- Configuration schema and precedence: Configuration reference.
- Running without a container: Run and configure the server.
- Object-storage details: Serve media from S3.
Build from source
The installer covers the dyndo CLI on macOS and
Linux, and Docker covers the server. Building from
source is the fallback for when neither fits — an unsupported platform, or
running the server without Docker.
Prerequisites
- A recent stable Rust toolchain with
cargo. The minimum supported version is pinned in the repository’srust-toolchain.toml; any current stable release satisfies it. git, to clone the repository.
Build
Clone the repository and build in release mode:
git clone https://github.com/matvp91/dyndo.git
cd dyndo
cargo build --release
This produces both binaries under target/release/:
target/release/dyndo— the CLI.target/release/dyndo-server— the server.
Run them straight from there, or put them on your PATH.
Install the CLI on your PATH
cargo install copies the dyndo binary into ~/.cargo/bin, which the Rust
installer already adds to your PATH:
cargo install --path crates/dyndo-cli
dyndo --version
You can now use every CLI command — index, dash,
hls — exactly as the rest of this book describes.
Run the server
The dyndo-server binary reads the same configuration as the Docker image
(built-in defaults, then config.yaml, then DYNDO_* environment variables).
Point it at a storage root and run it:
DYNDO_FS__ROOT=./assets ./target/release/dyndo-server
dyndo-server listening on http://0.0.0.0:8080
Everything in Run and configure the server applies — the
same config keys, request URLs, and /health endpoint. Only the way you launch
the process differs.
Next steps
- Configure and operate the server: Run and configure the server.
- The full command-line surface: dyndo CLI reference.
- Prefer prebuilt binaries after all? Install the CLI.
dyndo CLI
dyndo is the command-line front-end for indexing media sources into
asset.json descriptors and rendering manifests offline. It is the binary
produced by the dyndo-cli crate.
dyndo <COMMAND>
| Command | Purpose |
|---|---|
index | Build or update an asset.json descriptor from CMAF and WebVTT sources. |
dash | Render a DASH MPD from an asset.json. |
hls | Render HLS playlists from an asset.json into a directory. |
Global options
| Option | Description |
|---|---|
-h, --help | Print help (available on the top-level command and every subcommand). |
-V, --version | Print the version. |
Storage root
All file paths are read and written through an OpenDAL
filesystem operator rooted at a single directory. By default that root is the
current working directory; override it with the OPENDAL_FS_ROOT environment
variable:
| Variable | Description | Default |
|---|---|---|
OPENDAL_FS_ROOT | Root directory for all reads and writes. | . (current directory) |
Within that root, a track’s source path is always resolved relative to the descriptor that references it, not relative to your shell’s working directory. See Understand how paths resolve.
Exit behavior
Every command runs to completion or aborts. On any runtime error — a missing
file, malformed descriptor JSON, an input that isn’t valid CMAF, or an
unsupported codec or file format — the command prints the error and exits with
status 1. Command-line usage errors (an unknown flag, no inputs) print usage
and exit with status 2. There is no partial success: index does not skip a
bad input and continue, and a failed dash/hls writes nothing.
Commands
dyndo index
Build or update an asset.json descriptor from one or more track descriptors.
Each input becomes one track. New tracks are probed from their file; tracks
already in the descriptor keep their metadata as-is, with only explicit
overrides applied.
Synopsis
dyndo index [OPTIONS] <INPUTS>...
Options
| Option | Description | Default |
|---|---|---|
<INPUTS>... | Track descriptor(s), one per track: <path>[,language=..][,role=..]. Positional, at least one required. | (required) |
-o, --output <OUTPUT> | Output descriptor path. | asset.json |
-h, --help | Print help. |
Descriptor syntax
Each input is a comma-separated descriptor whose first field is the file
path; the remaining fields are key=value overrides:
language— ISO-639-2 code; overrides the language probed from the file. Audio and text only.role— the track’s purpose; never probed, so this is the only way to set it apart from editing the JSON. Audio and text only. Audio:main,alternate,commentary,dub,description,enhanced-audio-intelligibility. Text:subtitle,caption,forced-subtitle.
A bare video.mp4 is the zero-override case. An empty value (language=)
means “no override”. When a key is repeated, the last occurrence wins. A video
input with language/role, an unknown key, or a role invalid for the
track’s type each abort the run.
Input formats
The file extension (matched case-insensitively) selects how an input is read:
| Extension | Format | Becomes |
|---|---|---|
.mp4 | CMAF — fragmented MP4 | A video, audio, or text (wvtt) track, by media handler. |
.vtt | Raw WebVTT | A text track served by chunking the file on the fly. |
Any other extension aborts with an unsupported-format error naming the supported ones.
A CMAF input must be valid CMAF or the run aborts:
- a fragmented MP4 whose
moovcontains exactly one track; - a single global
sidxsegment index; and - a supported codec.
Description
For each input, index decides between two cases by looking up the input’s
path in the existing descriptor (when --output already exists, it is loaded
first):
- New path — the file is probed: its header region is read, the track kind
is determined from its media handler, codec and per-type metadata are
extracted, any
language/roleoverrides are applied, and a track entry is appended with a freshly generated, thereafter-pinnedid. - Known path — the descriptor’s stored metadata is kept as-is; the file
is not re-probed for metadata, so hand-edits to the JSON survive a re-index.
Explicit
language=/role=overrides are the only mutation, and theidnever changes.
The descriptor is then written to --output as pretty-printed JSON:
wrote asset.json (3 tracks)
Input paths are resolved relative to the output descriptor’s directory, and
the path stored for each track is that same descriptor-relative path. See
path resolution.
Note that loading an existing descriptor probes every listed track’s header, so all sources already in the descriptor must still exist and parse — a re-index fails if one of them has gone missing.
Examples
Index a multi-rendition asset, tagging the audio:
dyndo index \
video_1080.mp4 \
video_720.mp4 \
audio_en.mp4,language=eng,role=main \
-o asset.json
Add a subtitle from a raw WebVTT file:
dyndo index subtitles_nl.vtt,language=nld -o asset.json
Set a role on a track that is already indexed (updates the entry in place — nothing else about it changes):
dyndo index audio_fr.mp4,role=dub -o asset.json
Write the descriptor into a subdirectory (inputs resolve relative to it):
dyndo index video.mp4 audio.mp4 -o out/asset.json
See also
- Index your CMAF sources — task-oriented guide.
- Add a subtitle track — text tracks from
.vtt. - asset.json descriptor — the output format.
dyndo dash
Generate a DASH MPD from an asset.json.
Synopsis
dyndo dash [OPTIONS]
Options
| Option | Description | Default |
|---|---|---|
-i, --input <INPUT> | Input asset.json path. | asset.json |
-o, --output <OUTPUT> | Output manifest path. | stream.mpd |
-c, --compact | Hoist a SegmentTemplate shared by all representations up to the AdaptationSet level. | off |
-h, --help | Print help. |
Description
dash reads the descriptor at --input, parses each track’s CMAF header to
recover its segment index, and writes a static MPD to --output:
wrote stream.mpd
The manifest is type="static" (video on demand). Each track becomes a
Representation inside an AdaptationSet for its content type (video,
audio, or text), carrying a SegmentTemplate with a SegmentTimeline
derived from the source’s sidx. Segment URLs use the $RepresentationID$ and
$Time$ template variables — <id>/init.mp4 and <id>/<time>.m4s — matching
the server’s segment routes.
Audio and text AdaptationSets carry a lang attribute, and tracks are
grouped into sets by (sample entry, timescale, language, role) — the fields
DASH requires to be uniform within a set. A track’s role becomes a Role
descriptor (scheme urn:mpeg:dash:role:2011); the description and
enhanced-audio-intelligibility audio roles additionally emit an
Accessibility descriptor. A text track with no role defaults to
Role@value="subtitle". See the Track roles reference for the
exact mapping.
Text tracks appear in the MPD when their source is CMAF wvtt; raw .vtt
sources are recorded in the descriptor but not yet advertised — on-the-fly
chunking of raw WebVTT is still being wired up.
The --compact flag
Without --compact, every Representation carries its own SegmentTemplate.
With it, a SegmentTemplate common to all representations in an AdaptationSet
is written once at the set level, ahead of the representations. The rendered
timeline is identical; only the structure and size differ.
The server always renders DASH in the compact form, so --compact makes the
CLI output match what the server serves.
Examples
dyndo dash -i asset.json -o stream.mpd
dyndo dash -i asset.json -o stream.mpd --compact
See also
- Generate manifests without the server.
dyndo hls— the HLS equivalent.
dyndo hls
Generate HLS playlists from an asset.json — a multivariant (master) playlist
plus one media playlist per advertised track — into an output directory.
Synopsis
dyndo hls [OPTIONS]
Options
| Option | Description | Default |
|---|---|---|
-i, --input <INPUT> | Input asset.json path. | asset.json |
-o, --output <OUTPUT> | Output directory for the playlists. | hls |
-h, --help | Print help. |
Description
HLS is a set of files, so --output is a directory rather than a single file.
hls writes:
index.m3u8— the multivariant playlist, listing every video variant and advertising the audio renditions viaEXT-X-MEDIA; and<id>.m3u8— one media playlist per advertised track, named by trackid.
wrote hls/ (1 master + 3 media)
The advertised tracks are the asset’s video and audio tracks. Text tracks
are not yet advertised in HLS — subtitle renditions (chunked on the fly from
raw .vtt, or served from CMAF wvtt) are still being wired up and currently
get neither an EXT-X-MEDIA entry nor a media playlist.
Each media playlist is EXT-X-PLAYLIST-TYPE:VOD, begins with an EXT-X-MAP
pointing at the init segment, lists each segment with its EXTINF duration, and
ends with EXT-X-ENDLIST. Segment URLs are <id>/init.mp4 and
<id>/<time>.m4s, the same paths the server and the
DASH manifest use.
Examples
dyndo hls -i asset.json -o hls
Resulting layout:
hls/
├── index.m3u8
├── video_1080_avc1_4807228.m3u8
├── video_720_avc1_3205265.m3u8
└── audio_nld_2_mp4a_196918.m3u8
Notes
- Audio renditions are grouped by sample-entry code (
GROUP-ID, e.g.mp4a,ec-3). Two audio tracks sharing a sample entry but differing in codec profile (for example AAC-LC vs HE-AAC) collapse into one group whoseCODECSreflects the first track seen. - Track roles drive rendition selection and accessibility signalling: the
default audio rendition is the first
main-role track (or the first audio track when none ismain), opt-in audio roles are not auto-selected, and accessibility roles carryCHARACTERISTICSattributes. A rendition’sNAMEis its language, qualified by its role when one is set (e.g.nld (main)). See the Track roles reference. - Every variant’s
BANDWIDTHis the video track’s bandwidth plus the highest-bandwidth rendition of its audio group. - The
#EXT-X-INDEPENDENT-SEGMENTStag is emitted in the multivariant playlist.
See also
- Generate manifests without the server.
dyndo dash— the DASH equivalent.
dyndo-server
dyndo-server is the dynamic packaging HTTP server, produced by the
dyndo-server crate and built on Axum. It
serves DASH and HLS manifests and CMAF segments generated on the fly from
asset.json descriptors.
Running
dyndo-server
The server takes no command-line arguments; all settings come from configuration (see Configuration). On startup it:
- loads configuration (defaults, then
config.yaml, thenDYNDO_*environment variables); - builds the storage operator for the selected backend; and
- binds the configured address and begins serving.
dyndo-server listening on http://0.0.0.0:8080
If configuration cannot be loaded, or the selected storage backend is misconfigured, the server exits during startup rather than serving errors per request.
What it serves
For every descriptor in the storage backend, the server exposes both a DASH and an HLS stream over a shared set of segment routes. Manifests are generated per request by parsing each source’s CMAF header; media segments are returned as byte-range reads from the original files. Nothing is written back to storage.
In this section
- HTTP routes — the complete route table, path grammar, content types, and status codes.
- Configuration — the config schema, layering, and environment-variable mapping.
HTTP routes
Every stream lives under /<asset>/<protocol>/<resource>. All routes are GET.
Route table
| Path | Description | Content-Type |
|---|---|---|
/<asset>/dash/index.mpd | The asset’s DASH manifest (MPD). | application/dash+xml |
/<asset>/hls/index.m3u8 | The asset’s HLS multivariant (master) playlist. | application/vnd.apple.mpegurl |
/<asset>/hls/<repr>.m3u8 | An HLS rendition’s media playlist. | application/vnd.apple.mpegurl |
/<asset>/<protocol>/<repr>/init.mp4 | A representation’s initialization segment. | video/mp4, audio/mp4, or application/mp4 |
/<asset>/<protocol>/<repr>/<time>.m4s | The media segment starting at presentation <time>. | video/mp4, audio/mp4, or application/mp4 |
A raw-WebVTT text track answers on the same segment routes with text/vtt (its
init.mp4 is empty — a raw source has no initialization segment), though such
tracks are not yet referenced by the generated manifests.
Health check
Separate from the streaming routes, the server answers a liveness probe:
| Path | Description | Content-Type |
|---|---|---|
/health | Liveness probe. Returns 200 OK with an empty body. | (none) |
/health is a fixed route registered ahead of the catch-all, so it never
shadows a stream — every streaming route carries a /dash/ or /hls/ infix.
Use it for container and load-balancer health checks; see
Deploy with Docker.
Path grammar
<asset>— the descriptor’s path relative to the storage root. It may contain slashes: a descriptor atassets/movies/big/asset.json(with storage rootassets/) is addressed asmovies/big/asset.json. The full descriptor filename, including its extension, is part of the path.<protocol>—dashorhls.<repr>— a representationidexactly as recorded in the descriptor (for examplevideo_1080_avc1_4807228).<time>— the presentation start time of a segment, an integer in the track’s timescale (see Segment addressing).
The <asset> portion is variable-length and precedes the fixed <protocol>
infix, so the server matches on the rightmost /dash/ or /hls/ in the
path. A descriptor whose directory happens to be named dash or hls still
resolves correctly.
Manifests are protocol-specific; segments are not
The init.mp4 and <time>.m4s routes return the same CMAF bytes under either
<protocol> prefix — media segments are shared across protocols, and only the
manifest differs. These two requests fetch identical data:
/asset.json/dash/video_1080_avc1_4807228/init.mp4
/asset.json/hls/video_1080_avc1_4807228/init.mp4
See One source, two protocols for why.
Segment addressing
A media segment is addressed by its presentation start time, an integer in
the track’s timescale, with a .m4s extension. The first segment of a track
whose sidx reports an earliest presentation time of 0 is therefore
<repr>/0.m4s; subsequent segments start at the cumulative sum of the preceding
segment durations. These are exactly the $Time$ values written into the DASH
SegmentTimeline and the segment URIs in the HLS media playlists, so players
never construct them by hand.
A request whose <time> does not fall on a segment boundary returns 404; a
<time> that is not an integer returns 400.
Status codes
| Code | When |
|---|---|
200 OK | The manifest or segment was generated and returned; also the /health probe. |
400 Bad Request | A segment <time> is not a valid integer. |
404 Not Found | The path is not a streaming route; the descriptor or a source file is missing; <repr> matches no representation; or <time> is not a segment boundary. |
500 Internal Server Error | The descriptor JSON is malformed, or a source file is unreadable or not valid, supported CMAF. |
The split between 404 and 500 reflects ownership: a missing object is
treated as a client addressing error (404), while a malformed descriptor
or broken media file is the server’s own content problem (500), because
the asset files are server-owned. Error responses carry a short plain-text
message describing the failure.
CORS
The server applies a permissive CORS policy — any origin, any method — so browser-based players can load manifests and segments cross-origin during development.
Configuration
dyndo-server is configured by layering three sources, each overriding the one
before it:
- built-in defaults,
- a
config.yamlfile, then DYNDO_-prefixed environment variables.
There are no command-line flags. Configuration is deserialized directly into OpenDAL’s backend config structs, so backend settings are exactly OpenDAL’s own.
Configuration file
The file is ./config.yaml in the working directory by default. Set
DYNDO_CONFIG to load a different path:
| Variable | Description |
|---|---|
DYNDO_CONFIG | Path to the YAML config file. If set, the file must exist or the server exits. If unset, a missing config.yaml is ignored (defaults + env are used). |
Schema
| Key | Type | Description | Default |
|---|---|---|---|
store | fs | s3 | Which storage backend serves assets. | fs |
server.host | string | Listen address. | 0.0.0.0 |
server.port | integer | Listen port. | 8080 |
fs | table | Local-filesystem backend settings (OpenDAL Fs). Required when store: fs. | (none) |
s3 | table | S3 backend settings (OpenDAL S3). Required when store: s3. | (none) |
Neither backend section is defaulted: whichever backend store selects must be
supplied (via file or environment) with the fields OpenDAL needs, or the server
fails to build its storage operator at startup.
fs backend
| Key | Description |
|---|---|
fs.root | Root directory that descriptors and media are read from. Required for store: fs. |
store: fs
fs:
root: ./assets
s3 backend
The s3 table is OpenDAL’s S3 configuration. The commonly used keys:
| Key | Description |
|---|---|
s3.bucket | Bucket name. Required for store: s3. |
s3.region | AWS region. |
s3.endpoint | Service endpoint URL. |
s3.root | Key prefix prepended to every object path. |
s3.access_key_id | Access key ID (or supply via AWS_ACCESS_KEY_ID). |
s3.secret_access_key | Secret access key (or supply via AWS_SECRET_ACCESS_KEY). |
store: s3
s3:
bucket: my-assets
region: eu-west-1
endpoint: https://s3.eu-west-1.amazonaws.com
root: /
Credentials may be omitted from the file and supplied through the standard
AWS_* environment variables, which OpenDAL’s S3 backend reads.
Environment variables
Every setting maps to a DYNDO_-prefixed variable. Nested keys are separated by
a double underscore (__); single underscores within a field name are
preserved.
| Variable | Sets |
|---|---|
DYNDO_STORE | store |
DYNDO_SERVER__HOST | server.host |
DYNDO_SERVER__PORT | server.port |
DYNDO_FS__ROOT | fs.root |
DYNDO_S3__BUCKET | s3.bucket |
DYNDO_S3__REGION | s3.region |
DYNDO_S3__ACCESS_KEY_ID | s3.access_key_id |
DYNDO_S3__SECRET_ACCESS_KEY | s3.secret_access_key |
The double-underscore rule is what keeps a name like access_key_id intact —
DYNDO_S3__ACCESS_KEY_ID nests as s3.access_key_id, not s3.access.key.id.
Precedence
Later layers win field by field (a deep merge), so you can commit a
config.yaml and override only what changes per environment:
# config.yaml sets port 8080 and fs.root ./assets;
# these env vars override just those two fields.
DYNDO_SERVER__PORT=9000 DYNDO_FS__ROOT=/srv/media dyndo-server
Startup errors
Configuration problems are reported at startup, not per request:
| Condition | Result |
|---|---|
DYNDO_CONFIG names a missing file | Exit: “DYNDO_CONFIG points to a missing file”. |
| YAML/env cannot be merged or deserialized into the schema | Exit: “failed to load configuration”. |
Selected backend rejected (e.g. s3 with no bucket, fs with no root) | Exit: “failed to build storage operator”. |
asset.json descriptor
The asset.json descriptor is the contract between the CLI and the server. The
CLI (index) writes it; the server reads it to generate
manifests and locate segments. It is deliberately small: it records per-track
metadata and a source path, and nothing else — no segment list, no byte
offsets, no timescale. Those are re-derived from each source at read time.
The file is pretty-printed JSON and safe to read, diff, and hand-edit.
Top-level structure
A descriptor is an object with a tracks array and two optional segmentation
fields:
{
"min_segment_length": 3000,
"segment_boundaries": [683640],
"tracks": [ /* track objects */ ]
}
Track order is preserved as written and is significant: within an HLS audio
group, the default rendition is the first main-role track, or the first track
when none is marked main. index appends tracks in the order you pass them.
Segmentation
Both fields are optional and control how each track’s CMAF fragments are grouped into served segments. Grouping is applied when manifests and segments are served — the CMAF files are never modified, so these fields can be edited at any time. When unset, they are omitted from the written descriptor.
| Field | Type | Description |
|---|---|---|
min_segment_length | integer (optional) | Minimum length of a served segment, in milliseconds. Whole fragments (for video: GOPs) are grouped until a segment reaches at least this length — fragment boundaries are never split. Omitted or 0: every fragment is served as its own segment. The last segment before a splice point or the end of the track may be shorter. |
segment_boundaries | array of integers (optional) | Splice points, in milliseconds from the start of the presentation, e.g. for ad insertion. A served segment never spans one, so a segment edge exists at every splice point. Treated as a set: order and duplicates don’t matter. Each point is snapped per track to the nearest fragment boundary (audio fragment rasters cannot hit arbitrary millisecond positions); an exact tie snaps earlier. |
Track object
Each track is tagged by a type discriminator: "video", "audio", or
"text". All track types share these fields:
| Field | Type | Description |
|---|---|---|
type | string | Track kind: video, audio, or text. |
id | string | Representation id (see Representation ids). The key must be present; an empty string is filled with a freshly generated id whenever the descriptor is read. |
path | string | Source file path, relative to the descriptor’s directory. |
fourcc | string (written, ignored on read) | Sample-entry four-character code (e.g. avc1, mp4a, wvtt), written as a debugging aid. It is recomputed from the probed source on every write and ignored when the descriptor is read. Raw .vtt tracks have no sample entry, so the field is omitted for them. |
Unknown fields are ignored on read. Type-specific fields follow.
Video tracks
| Field | Type | Description |
|---|---|---|
width | integer | Visual width, in pixels. |
height | integer | Visual height, in pixels. |
{
"id": "video_1080_avc1_4807228",
"path": "video_1080.mp4",
"type": "video",
"width": 1920,
"height": 1080,
"fourcc": "avc1"
}
Audio tracks
| Field | Type | Description |
|---|---|---|
sample_rate | integer | Sampling rate, in Hz. |
channels | integer | Number of audio channels (e.g. 2 for stereo, 6 for 5.1). |
language | string | ISO 639-2 language code. Defaults to und when absent and is always written. |
role | string (optional) | The track’s declared purpose. Omitted when unset. One of main, alternate, commentary, dub, description, enhanced-audio-intelligibility. See Track roles. |
{
"id": "audio_nld_2_mp4a_196918",
"path": "audio_nl.mp4",
"type": "audio",
"sample_rate": 48000,
"channels": 2,
"language": "nld",
"role": "main",
"fourcc": "mp4a"
}
Text tracks
A text track’s source is WebVTT in one of two forms, and dyndo does the rest at
serve time: a raw .vtt file is chunked, and CMAF wvtt (WebVTT in
ISO-BMFF) is served like any other CMAF track. Both forms sit in the descriptor
the same way; a raw .vtt entry simply has no fourcc.
Text-track serving is still being completed: CMAF
wvtttracks are advertised in DASH manifests today, while HLS subtitle renditions and the on-the-fly chunking of raw.vttsources are not wired up yet. The descriptor format below is stable either way.
| Field | Type | Description |
|---|---|---|
language | string | ISO 639-2 language code. Defaults to und when absent and is always written. |
role | string (optional) | The track’s declared purpose. Omitted when unset (rendered as subtitle). One of subtitle, caption, forced-subtitle. See Track roles. |
A CMAF wvtt track and a raw .vtt track:
{
"id": "text_eng_wvtt_586",
"path": "text_wvtt_eng.mp4",
"type": "text",
"language": "eng",
"role": "caption",
"fourcc": "wvtt"
}
{
"id": "text_und",
"path": "subtitles_nl.vtt",
"type": "text",
"language": "nld"
}
Representation ids
The id is derived from the track’s properties when the track is first probed:
| Type | Pattern | Example |
|---|---|---|
| Video | video_<height>_<fourcc>_<bandwidth> | video_1080_avc1_4807228 |
| Audio | audio_<language>_<channels>_<fourcc>_<bandwidth> | audio_nld_2_mp4a_196918 |
Text (CMAF wvtt) | text_<language>_<fourcc>_<bandwidth> | text_eng_wvtt_586 |
Text (raw .vtt) | text_<language> | text_und |
<bandwidth> is the average bitrate in bits per second, computed from the
source’s segment sizes and total duration. The id is the representation name in
every manifest and the <repr> component of every segment URL.
Ids are pinned. An id is generated once, when the track is first indexed,
from the values probed out of the source at that moment — and written verbatim
ever after. Later edits to the descriptor (a corrected language, an added
role) deliberately do not re-derive the id, so segment URLs stay stable
for anything already consuming the stream. The parts of an id are a naming
convention, not live metadata. Note that a raw .vtt id takes its language
from the probe — WebVTT files declare none, so it is und even when you set
language= at index time.
Path resolution
path is always relative to the descriptor’s own directory, normalized
(.. segments are resolved). A descriptor at assets/asset.json with
"path": "video.mp4" refers to assets/video.mp4; "path": "../shared/a.mp4"
refers to assets/../shared/a.mp4 → shared/a.mp4. This keeps a descriptor
portable: move the descriptor and its sources together and every path stays
valid.
Complete example
An asset with two video renditions, one audio track, and a subtitle track:
{
"tracks": [
{
"id": "video_1080_avc1_4807228",
"path": "video_1080.mp4",
"type": "video",
"width": 1920,
"height": 1080,
"fourcc": "avc1"
},
{
"id": "video_720_avc1_3205265",
"path": "video_720.mp4",
"type": "video",
"width": 1280,
"height": 720,
"fourcc": "avc1"
},
{
"id": "audio_nld_2_mp4a_196918",
"path": "audio_nl.mp4",
"type": "audio",
"sample_rate": 48000,
"channels": 2,
"language": "nld",
"role": "main",
"fourcc": "mp4a"
},
{
"id": "text_und",
"path": "subtitles_nl.vtt",
"type": "text",
"language": "nld"
}
]
}
A note on hand-editing
The descriptor is safe to edit, and re-running index will
not undo your edits: tracks already in the descriptor keep their metadata as-is
on a re-index. The fields intended for hand-editing are the language and
role on audio and text tracks, the top-level
segmentation fields, and track order (which picks the HLS
default rendition). Editing an id also works but changes every URL under
which the track is served.
Two things to keep in mind:
- Metadata fields like
widthorsample_ratedescribe the source as probed; editing them does not change the media. - Because
indexleaves known tracks untouched, it will not notice that a source file’s content changed. To re-probe a track, remove its entry from the JSON (or delete the descriptor) and index the file again.
Track roles
A track’s role is its author-declared purpose. It applies to audio and text
tracks only, is never probed from the media, and is stored in the descriptor’s
role field. This page is the exact reference
for the role vocabulary and how each role is rendered into DASH and HLS.
To set a role, see Label tracks with roles. Roles are
validated at index time: a role on a video track, a role that doesn’t match the
track’s media type, or an unknown value aborts the run. A role never affects a
track’s representation id.
Audio roles
| Value | Meaning |
|---|---|
main | The primary audio. |
alternate | An alternate version of the main audio. |
commentary | Commentary (e.g. director’s commentary). |
dub | A dubbed rendition in another language. |
description | Audio description for viewers who are blind or have low vision. |
enhanced-audio-intelligibility | Dialogue enhanced for intelligibility. |
Text roles
| Value | Meaning |
|---|---|
subtitle | Translation subtitles (dialogue only). The default when no role is set. |
caption | SDH / closed captions (dialogue plus non-dialogue sound description). |
forced-subtitle | Forced narrative subtitles (foreign dialogue or on-screen text), shown even when subtitles are otherwise off. |
DASH mapping
Roles are emitted as descriptors on the track’s AdaptationSet; tracks are
grouped into adaptation sets by (sample entry, timescale, language, role).
Role
Scheme urn:mpeg:dash:role:2011; value is the role string verbatim.
| Track | Role emitted |
|---|---|
| Audio with a role | Role@value = the role. |
| Audio with no role | none. |
| Text | Role@value = the role, defaulting to subtitle when unset. |
Accessibility
Scheme urn:tva:metadata:cs:AudioPurposeCS:2007, emitted only for two audio
roles:
| Audio role | Accessibility@value |
|---|---|
description | 1 |
enhanced-audio-intelligibility | 8 |
No other role emits an Accessibility descriptor.
HLS mapping
Roles set the selection attributes on the EXT-X-MEDIA rendition entries in the
multivariant playlist.
Audio renditions
Audio tracks are grouped by sample-entry code. Within a group:
| Attribute | Rule |
|---|---|
DEFAULT | YES on the first main-role rendition; if no member is main, on the first rendition. NO otherwise. |
AUTOSELECT | NO for the opt-in roles (commentary, description, enhanced-audio-intelligibility) unless the rendition is the group default; YES otherwise. |
CHARACTERISTICS | public.accessibility.describes-video for description; public.accessibility.enhances-speech-intelligibility for enhanced-audio-intelligibility; absent otherwise. |
NAME | The rendition’s language, qualified by its role when one is set — nld, eng (commentary). A counter disambiguates collisions (eng (2)). |
DEFAULT=YES implies AUTOSELECT=YES, so the group default is always
auto-selected even when its role is opt-in.
Subtitle renditions
HLS subtitle renditions are not emitted yet — text tracks currently appear in DASH manifests only (CMAF
wvttsources). The mapping below is the design that on-the-fly text serving is being built toward.
All text tracks share one TYPE=SUBTITLES group.
| Attribute | Rule |
|---|---|
DEFAULT | Always NO — the viewer enables subtitles explicitly. |
AUTOSELECT | YES only for forced-subtitle; NO for subtitle and caption. |
FORCED | YES only for forced-subtitle. |
CHARACTERISTICS | public.accessibility.transcribes-spoken-dialog,public.accessibility.describes-music-and-sound for caption; absent otherwise. |
See also
- Label tracks with roles — how to set them.
- asset.json descriptor — the
rolefield in context. dyndo dashanddyndo hls— the manifests roles appear in.
The thin-pointer approach
This page explains the central design decision in dyndo — serving adaptive streams from a thin pointer to your media rather than a repackaged copy of it — and the trade-offs that come with it.
The conventional approach: package ahead of time
A traditional packager runs once, ahead of playback, and writes a full set of DASH and HLS renditions to disk. For each source it produces init segments, thousands of media segments, and the manifests that index them. The output is a complete, self-contained copy of your media, restructured for streaming.
This works, but it has costs:
- Storage is duplicated. The packaged output is a second full copy of the media, often in two layouts (one for DASH, one for HLS).
- Storage is coupled to protocol. Adding or changing a protocol means re-running the packager and writing yet more files.
- The segment layout is frozen. The manifest and the on-disk segments must agree, so they are produced together and kept together.
dyndo’s approach: keep the source, point at it
dyndo inverts this. It never writes media. Instead, indexing records a small
descriptor — asset.json — that says only
what the tracks are and where their source files live:
{
"id": "video_1080_avc1_4807228",
"path": "video_1080.mp4",
"type": "video",
"width": 1920,
"height": 1080,
"fourcc": "avc1"
}
Notice what is not here: no list of segments, no byte offsets, no init-segment range, not even a timescale, no per-protocol layout. The descriptor is a pointer, not a copy. At request time the server reads the source’s header and re-derives everything else (the next page covers how).
This yields three properties:
- No duplicated storage. Your original CMAF files are the served media. The descriptor adds a few hundred bytes per asset.
- One source of truth. The segment map lives in exactly one place — the
source’s own
sidx— and is never copied into the descriptor, so the two can never drift apart. - Protocol at the edge. Because segments are never protocol-specific (they are just CMAF), adding HLS alongside DASH costs a manifest generator, not a second copy of the media. See One source, two protocols.
What you trade for it
The thin pointer moves work from packaging time to request time, so it is not free:
- Per-request parsing. Every manifest request parses the source headers afresh rather than reading a finished file. That work is deliberately bounded (see Reading a source), but it is real, and a production deployment would cache generated manifests.
- Sources must already be CMAF. dyndo indexes and serves CMAF; it does not
transcode. The media must already be fragmented MP4 with a global
sidx. Producing that is a one-time, out-of-band step (for example with ffmpeg). - The source files must stay put. The descriptor points at them by relative path; move a source without moving its descriptor and the pointer dangles.
When this fits
The thin pointer is a good fit when you already have (or can cheaply produce) CMAF masters and want to serve them over multiple protocols without maintaining a second, protocol-shaped copy of your library. It trades a little repeated computation for a large reduction in stored bytes and a single, canonical segment map.
See also
- Reading a source: headers and the segment index — how the descriptor stays this thin.
- One source, two protocols — how one set of segments serves both DASH and HLS.
Reading a source: headers and the segment index
The thin-pointer approach only works if dyndo can recover a
source’s entire segment layout cheaply and repeatedly. This page explains how it
does that: by reading a small header region and re-deriving the segment index
from the sidx, without ever touching the media payload.
The shape of a CMAF track
A CMAF track file is laid out, roughly, as:
┌───────┬─────────────┬────────┬──────┬──────┬──────┬──────┬─────
│ ftyp │ moov │ sidx │ moof │ mdat │ moof │ mdat │ …
└───────┴─────────────┴────────┴──────┴──────┴──────┴──────┴─────
brand track header segment ─── media fragments (the bulk) ───
(one track) index
ftyp+moov— the initialization data: brands, and the single track’s timescale, codec sample entry, and handler. Together these are the init segment.sidx— the segment index: one reference per (sub)segment, each giving that segment’s byte size and duration.moof+mdatpairs — the media fragments. Themdatboxes hold the actual coded samples and account for essentially the whole file.
Everything dyndo needs to describe and address the track lives in the first
three boxes. The mdat bodies matter only when a specific segment is
requested — and then only that segment’s byte range.
Probing: read the header, stop at the first fragment
To index or serve a track, dyndo streams the file from the start and parses
boxes until it has the moov, the sidx, and the first moof — then stops. The
first moof marks the end of the header region; the parser never reads the
mdat that follows it. Boxes it doesn’t care about are skipped by length.
From those boxes it assembles:
- the track metadata (codec and its RFC 6381
parameters, dimensions or sample rate/channels, language) from the
moov’s single track and the first fragment’s timing; and - the segment index from the
sidx.
Re-deriving the segment map from the sidx
The sidx is what makes the descriptor able to omit the segment list entirely.
Each of its references gives a segment’s size in bytes and duration in the track
timescale. Walking the references in order, dyndo reconstructs, for every
segment:
- its byte offset — a running sum of the preceding segment sizes, starting
just after the
sidx; and - its presentation time — a running sum of the preceding durations, starting
at the
sidx’s earliest presentation time.
Those two running sums are exactly what a manifest needs (the $Time$ values in
a DASH SegmentTimeline, the segment URIs in an HLS media playlist) and what a
segment request needs (the byte range to read for a given <time>). The sidx
is the segment map; dyndo reads it, never copies it.
By default every sidx reference becomes one served segment. The descriptor’s
optional segmentation fields
(min_segment_length, segment_boundaries) group consecutive references into
larger served segments at serve time — contiguous byte ranges merge into one —
without touching the source file or the index itself. Grouping decisions are
made in exact integer arithmetic on each track’s native timescale values, so
tracks with different timescales (video at 90000, audio at 48000) reach
identical decisions about where a segment edge falls — the two manifests and
the segment routes always agree.
One source type opts out of all of this: a raw .vtt subtitle file has no
boxes and no sidx — the file itself is the source of truth, and serving text
from it (chunked, or packaged as wvtt) happens on the fly. That text pipeline
is still being completed.
Why an 800 MB file parses like an 8 MB one
The header region — moov + sidx + first moof — is a fixed, small part of
the file regardless of how long the track is. A longer track has a longer
sidx (one reference per segment) and more mdat bytes, but dyndo reads the
sidx and stops before the mdat. Parsing cost tracks the number of segments,
not the size of the media, so an 800 MB source is parsed from roughly the same
~10 KB header region as an 8 MB one. The mdat body is never fetched during
indexing or manifest generation.
Reading a segment
When a player later requests <repr>/<time>.m4s, dyndo re-derives the index the
same way, finds the segment whose cumulative start time equals <time>, and
issues a single byte-range read for that segment’s offset..offset+size. Init
segments (init.mp4) are the ftyp+moov range at the front of the file. In
both cases dyndo reads only the bytes that segment occupies — the rest of the
file is never transferred.
See also
- The thin-pointer approach — why the descriptor can be this small.
- asset.json descriptor — what indexing records.
- HTTP routes — how
<time>addresses a segment.
One source, two protocols
dyndo serves the same media as both DASH and HLS. This page explains how it does that from a single set of files, and why the two protocols share everything except their manifests.
Two protocols, one disagreement
DASH and HLS are both adaptive-streaming protocols: a player fetches a manifest that lists the available renditions and their segments, then downloads segments, switching renditions as bandwidth allows. They differ almost entirely in the manifest:
- DASH uses an XML Media Presentation Description (
.mpd) that groups representations into adaptation sets and describes segments with aSegmentTemplateandSegmentTimeline. - HLS uses a set of
.m3u8text playlists — a multivariant playlist that lists variants, and one media playlist per rendition listing its segments.
What they do not need to disagree on is the media itself. Both can play CMAF: fragmented MP4 with an init segment and independently addressable media segments. This is the property dyndo leans on entirely.
Segments are shared; only the manifest is protocol-specific
Because the segments are the same CMAF for either protocol, dyndo stores and serves exactly one set of them, and generates two manifests over them:
┌───────────────────────┐
asset.json ──▶ dyndo │ DASH manifest (.mpd) │ protocol-specific
│ HLS manifests (.m3u8)│
└───────────┬───────────┘
│ both reference the same URLs:
▼
<repr>/init.mp4 , <repr>/<time>.m4s shared CMAF
In the server this shows up directly in the routes:
the manifest routes branch on protocol (dash/index.mpd vs hls/index.m3u8),
but the segment routes (<repr>/init.mp4, <repr>/<time>.m4s) do not. A segment
request returns the same bytes whether it arrived under the dash/ or the hls/
prefix — the prefix is just part of a URL the manifest happened to emit.
The manifests agree by construction
Both manifests are generated from the same parsed source and the same
re-derived segment index, so they necessarily describe the
same segments with the same timing and the same URLs. There is no separate
packaging step for each protocol that could fall out of sync — the DASH
SegmentTimeline and the HLS EXTINF/EXT-X-MAP entries are two renderings of
one index.
That is why the same <repr>/<time>.m4s value appears in both a DASH
SegmentTimeline $Time$ and an HLS media-playlist URI: they are computed from
the same running sum of segment durations.
Roles render per protocol, from one source
A track’s role — its author-declared purpose, such as a commentary audio track
or a forced-subtitle text track — is recorded once in the descriptor and then
rendered into whatever each protocol uses to express it. DASH emits Role and
Accessibility descriptors; HLS emits DEFAULT/AUTOSELECT flags and
CHARACTERISTICS attributes. As with segments, there is one source of truth —
the descriptor’s role — and two renderings of it, so the two manifests describe
the same track the same way. See the DASH and
HLS references for the per-protocol output.
Consequences
- Adding a protocol is adding a manifest generator, not a second copy of the media. Everything below the manifest — segment addressing, byte-range reads, the descriptor — is reused unchanged.
- A player picks the protocol; the media is identical. Serving an Apple-ecosystem client over HLS and a browser player over DASH costs nothing extra in storage or indexing.
- Segment routes need no protocol knowledge, which keeps the hot path (the many segment requests during playback) simple and identical for both.
See also
- The thin-pointer approach — why there’s only one copy of the media.
- Reading a source: headers and the segment index — the shared index both manifests render.
- HTTP routes — where the shared/branching split lives in the API.