Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

dyndo is 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  │
                                                        └────────────────────────┘
  1. Index your CMAF files once with the dyndo CLI to produce asset.json.
  2. 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:

ToolWhat it doesHow to get it
dyndo (CLI)Index CMAF and WebVTT sources into asset.json and render manifests offline.The one-line installer.
dyndo-serverGenerate 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:

  • TutorialI’m new here. A single guided lesson that takes you from nothing to a playing stream.
  • How-to guidesI have a task to do. Focused recipes: index sources, add subtitles, label tracks with roles, run the server, serve from S3, deploy with Docker.
  • ReferenceI need to look something up. Exact, exhaustive descriptions of every command, route, config key, descriptor field, and track role.
  • ExplanationI 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.

MediaCodecSample entry
VideoAVC / H.264avc1
VideoHEVC / H.265hvc1, hev1
VideoAV1av01
AudioAACmp4a
AudioDolby Digital (AC-3)ac-3
AudioDolby Digital Plus (E-AC-3)ec-3
TextWebVTT in ISO-BMFFwvtt

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 dyndo CLI — 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=/assets points the server’s storage root at that mount.
  • -p 8080:8080 publishes 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:

  1. installed the dyndo CLI;
  2. produced two CMAF sources;
  3. indexed them into a tiny asset.json descriptor; and
  4. 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

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 a moov with exactly one track, a single global sidx segment 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, index won’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 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 wvtt tracks are advertised in DASH manifests today, while manifest advertisement for raw .vtt tracks 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:

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

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

RoleUse it forEffect on players
mainThe primary audio.Becomes the default audio rendition.
alternateAn alternate mix of the main audio.Auto-selectable (e.g. to match the viewer’s language).
dubA dubbed rendition in another language.Auto-selectable (e.g. to match the viewer’s language).
commentaryCommentary, e.g. director’s.Opt-in: never auto-selected, the viewer chooses it.
descriptionAudio description for blind / low-vision viewers.Opt-in, and flagged as accessible audio description.
enhanced-audio-intelligibilityDialogue 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 wvtt sources); HLS subtitle renditions land next. See Track roles for the exact per-protocol state.

RoleUse it forEffect on players
subtitleTranslation subtitles (dialogue only).Off by default; the viewer turns them on. This is also the default when no role is set.
captionSDH / closed captions (dialogue plus non-dialogue sound).Off by default; flagged with the SDH accessibility characteristics.
forced-subtitleForced 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 (or language) on a video input;
  • a role that doesn’t apply to the track’s media type — e.g. subtitle on audio, or main on text; or
  • an unknown role value.

Next steps

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 --compact here 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

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
  • store selects the storage backend: fs (local disk) or s3.
  • server.host / server.port set the listen address.
  • The fs: section configures the local-filesystem backend; its root is 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

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__BUCKET is required — the server fails to start without it.
  • DYNDO_S3__REGION and DYNDO_S3__ENDPOINT identify the S3 service.
  • Every S3 setting maps to a DYNDO_S3__* variable (double underscore between segments, single underscores preserved within a name), so DYNDO_S3__ACCESS_KEY_ID sets s3.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.yaml free 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

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:8080 publishes the port. The server binds 0.0.0.0 inside 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=/assets points the filesystem backend at the mount. fs is 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

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’s rust-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 commandindex, 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

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>
CommandPurpose
indexBuild or update an asset.json descriptor from CMAF and WebVTT sources.
dashRender a DASH MPD from an asset.json.
hlsRender HLS playlists from an asset.json into a directory.

Global options

OptionDescription
-h, --helpPrint help (available on the top-level command and every subcommand).
-V, --versionPrint 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:

VariableDescriptionDefault
OPENDAL_FS_ROOTRoot 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

OptionDescriptionDefault
<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, --helpPrint 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:

ExtensionFormatBecomes
.mp4CMAF — fragmented MP4A video, audio, or text (wvtt) track, by media handler.
.vttRaw WebVTTA 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 moov contains exactly one track;
  • a single global sidx segment 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/role overrides are applied, and a track entry is appended with a freshly generated, thereafter-pinned id.
  • 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 the id never 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

dyndo dash

Generate a DASH MPD from an asset.json.

Synopsis

dyndo dash [OPTIONS]

Options

OptionDescriptionDefault
-i, --input <INPUT>Input asset.json path.asset.json
-o, --output <OUTPUT>Output manifest path.stream.mpd
-c, --compactHoist a SegmentTemplate shared by all representations up to the AdaptationSet level.off
-h, --helpPrint 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

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

OptionDescriptionDefault
-i, --input <INPUT>Input asset.json path.asset.json
-o, --output <OUTPUT>Output directory for the playlists.hls
-h, --helpPrint 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 via EXT-X-MEDIA; and
  • <id>.m3u8 — one media playlist per advertised track, named by track id.
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 whose CODECS reflects 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 is main), opt-in audio roles are not auto-selected, and accessibility roles carry CHARACTERISTICS attributes. A rendition’s NAME is its language, qualified by its role when one is set (e.g. nld (main)). See the Track roles reference.
  • Every variant’s BANDWIDTH is the video track’s bandwidth plus the highest-bandwidth rendition of its audio group.
  • The #EXT-X-INDEPENDENT-SEGMENTS tag is emitted in the multivariant playlist.

See also

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:

  1. loads configuration (defaults, then config.yaml, then DYNDO_* environment variables);
  2. builds the storage operator for the selected backend; and
  3. 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

PathDescriptionContent-Type
/<asset>/dash/index.mpdThe asset’s DASH manifest (MPD).application/dash+xml
/<asset>/hls/index.m3u8The asset’s HLS multivariant (master) playlist.application/vnd.apple.mpegurl
/<asset>/hls/<repr>.m3u8An HLS rendition’s media playlist.application/vnd.apple.mpegurl
/<asset>/<protocol>/<repr>/init.mp4A representation’s initialization segment.video/mp4, audio/mp4, or application/mp4
/<asset>/<protocol>/<repr>/<time>.m4sThe 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:

PathDescriptionContent-Type
/healthLiveness 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 at assets/movies/big/asset.json (with storage root assets/) is addressed as movies/big/asset.json. The full descriptor filename, including its extension, is part of the path.
  • <protocol>dash or hls.
  • <repr> — a representation id exactly as recorded in the descriptor (for example video_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

CodeWhen
200 OKThe manifest or segment was generated and returned; also the /health probe.
400 Bad RequestA segment <time> is not a valid integer.
404 Not FoundThe 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 ErrorThe 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:

  1. built-in defaults,
  2. a config.yaml file, then
  3. 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:

VariableDescription
DYNDO_CONFIGPath 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

KeyTypeDescriptionDefault
storefs | s3Which storage backend serves assets.fs
server.hoststringListen address.0.0.0.0
server.portintegerListen port.8080
fstableLocal-filesystem backend settings (OpenDAL Fs). Required when store: fs.(none)
s3tableS3 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

KeyDescription
fs.rootRoot 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:

KeyDescription
s3.bucketBucket name. Required for store: s3.
s3.regionAWS region.
s3.endpointService endpoint URL.
s3.rootKey prefix prepended to every object path.
s3.access_key_idAccess key ID (or supply via AWS_ACCESS_KEY_ID).
s3.secret_access_keySecret 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.

VariableSets
DYNDO_STOREstore
DYNDO_SERVER__HOSTserver.host
DYNDO_SERVER__PORTserver.port
DYNDO_FS__ROOTfs.root
DYNDO_S3__BUCKETs3.bucket
DYNDO_S3__REGIONs3.region
DYNDO_S3__ACCESS_KEY_IDs3.access_key_id
DYNDO_S3__SECRET_ACCESS_KEYs3.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:

ConditionResult
DYNDO_CONFIG names a missing fileExit: “DYNDO_CONFIG points to a missing file”.
YAML/env cannot be merged or deserialized into the schemaExit: “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.

FieldTypeDescription
min_segment_lengthinteger (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_boundariesarray 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:

FieldTypeDescription
typestringTrack kind: video, audio, or text.
idstringRepresentation id (see Representation ids). The key must be present; an empty string is filled with a freshly generated id whenever the descriptor is read.
pathstringSource file path, relative to the descriptor’s directory.
fourccstring (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

FieldTypeDescription
widthintegerVisual width, in pixels.
heightintegerVisual height, in pixels.
{
  "id": "video_1080_avc1_4807228",
  "path": "video_1080.mp4",
  "type": "video",
  "width": 1920,
  "height": 1080,
  "fourcc": "avc1"
}

Audio tracks

FieldTypeDescription
sample_rateintegerSampling rate, in Hz.
channelsintegerNumber of audio channels (e.g. 2 for stereo, 6 for 5.1).
languagestringISO 639-2 language code. Defaults to und when absent and is always written.
rolestring (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 wvtt tracks are advertised in DASH manifests today, while HLS subtitle renditions and the on-the-fly chunking of raw .vtt sources are not wired up yet. The descriptor format below is stable either way.

FieldTypeDescription
languagestringISO 639-2 language code. Defaults to und when absent and is always written.
rolestring (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:

TypePatternExample
Videovideo_<height>_<fourcc>_<bandwidth>video_1080_avc1_4807228
Audioaudio_<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.mp4shared/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 width or sample_rate describe the source as probed; editing them does not change the media.
  • Because index leaves 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

ValueMeaning
mainThe primary audio.
alternateAn alternate version of the main audio.
commentaryCommentary (e.g. director’s commentary).
dubA dubbed rendition in another language.
descriptionAudio description for viewers who are blind or have low vision.
enhanced-audio-intelligibilityDialogue enhanced for intelligibility.

Text roles

ValueMeaning
subtitleTranslation subtitles (dialogue only). The default when no role is set.
captionSDH / closed captions (dialogue plus non-dialogue sound description).
forced-subtitleForced 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.

TrackRole emitted
Audio with a roleRole@value = the role.
Audio with no rolenone.
TextRole@value = the role, defaulting to subtitle when unset.

Accessibility

Scheme urn:tva:metadata:cs:AudioPurposeCS:2007, emitted only for two audio roles:

Audio roleAccessibility@value
description1
enhanced-audio-intelligibility8

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:

AttributeRule
DEFAULTYES on the first main-role rendition; if no member is main, on the first rendition. NO otherwise.
AUTOSELECTNO for the opt-in roles (commentary, description, enhanced-audio-intelligibility) unless the rendition is the group default; YES otherwise.
CHARACTERISTICSpublic.accessibility.describes-video for description; public.accessibility.enhances-speech-intelligibility for enhanced-audio-intelligibility; absent otherwise.
NAMEThe 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 wvtt sources). The mapping below is the design that on-the-fly text serving is being built toward.

All text tracks share one TYPE=SUBTITLES group.

AttributeRule
DEFAULTAlways NO — the viewer enables subtitles explicitly.
AUTOSELECTYES only for forced-subtitle; NO for subtitle and caption.
FORCEDYES only for forced-subtitle.
CHARACTERISTICSpublic.accessibility.transcribes-spoken-dialog,public.accessibility.describes-music-and-sound for caption; absent otherwise.

See also

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

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 + mdat pairs — the media fragments. The mdat boxes 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

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 a SegmentTemplate and SegmentTimeline.
  • HLS uses a set of .m3u8 text 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