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

Documentation

This book contains the project handbook, technical designs, architectural decisions, and links to the generated Rust API reference.

Reading the book

Install the pinned mdBook release once, then start the local server with live reload:

make install-doc-tools
make book-serve

Build the complete static documentation site with:

make site

The generated book starts at target/book/index.html. docs/SUMMARY.md controls the chapters and sidebar order.

API documentation

Application API documentation lives in //! and /// comments next to Rust code and is rendered by rustdoc. Build only that reference with:

make doc

The complete make site output places rustdoc under the book’s /api/ path. The documentation workflow publishes the combined site to GitHub Pages after changes reach main.

Implementation guide

The implementation guide explains how each module works and how to contribute to it, starting with a map of the code, the startup and request lifecycles, and the concurrency model.

Technical design documents

Technical design documents describe how a feature or subsystem should work before implementation. They capture requirements, data flow, interfaces, performance constraints, risks, and validation plans.

Architectural decision records

Architectural decision records (ADRs) capture durable choices, their context, and their consequences. ADRs are append-only: supersede an old decision with a new ADR instead of rewriting its history.

Guides

  • Operating the origin covers probes, shutdown, CORS, metrics, and limits for production deployments.
  • Performance budgets records how the budgets are measured and the current results.
  • Releasing explains the commit convention, automatic version tags, and the manual release workflow.
  • Mapper API reference specifies the service that resolves asset IDs to media locations.
  • Protocol conformance lists what the automated conformance suite checks and what still needs vendor validators.
  • Architecture records the current boundaries and should evolve with the crate.

Architecture

segmentor is a single binary that serves HLS and DASH by repackaging MP4 files on demand. The layers are:

HTTP layer (http, metrics)  ->  loaded assets (asset, hls, dash)  ->  media pipeline (source, mp4, media, segment, fmp4)

The media pipeline has no HTTP knowledge, the protocol renderers know nothing about transport, and the HTTP layer is the only place that deals with clients, limits, and failure modes.

New capabilities should be introduced as focused modules under src/. Application behavior belongs in process-level integration tests under tests/; implementation details use unit tests beside their source.

Using segmentor

Run the example

make serve

serves tests/fixtures/h264-aac.mp4 as the asset sample, using vod.example.toml. Asset IDs map to files beneath one canonical media root, and paths cannot escape it. Assets can also be resolved on demand from a mapper, including media on remote HTTP origins.

Endpoints

Each asset exposes:

/hls/{asset}/master.m3u8
/hls/{asset}/video/index.m3u8
/hls/{asset}/audio-{n}/index.m3u8   (one per audio track, numbered from 1)
/hls/{asset}/video/iframes.m3u8     (I-frame playlist, when there is video)
/hls/{asset}/video/iframes/{n}/media.m4s   (one keyframe as its own fragment)
/hls/{asset}/{track}/init.mp4
/hls/{asset}/{track}/segments/{index}/media.m4s
/hls/{asset}/subtitles/{language}/index.m3u8   (when the mapper lists subtitles)
/hls/{asset}/subtitles/{language}/sub.vtt
/dash/{asset}/manifest.mpd
/dash/{asset}/subtitles/{language}/sub.vtt
/dash/{asset}/{track}/init.mp4
/dash/{asset}/{track}/segments/{index}/media.m4s
/health   liveness
/ready    readiness (503 once shutdown begins)
/metrics  Prometheus text

Initialization and media responses support single and suffix byte ranges, If-Range, strong ETags, and immutable content-versioned URLs. Media URLs must carry the v query parameter the playlists emit; a missing or stale version is a 404. Media payloads are streamed through a bounded, backpressured reader instead of being buffered per request.

Configuration, logging, limits, CORS, and shutdown are described in Operating the origin.

Web player demo

demo/index.html is a single-file player (hls.js and dash.js, loaded from a CDN) that plays an asset over HLS or DASH and shows live server metrics parsed from /metrics next to it: request rate, throughput, per-route latency, errors, and resolver and cache events, plus player-side stats such as buffer, bandwidth, and dropped frames.

make serve   # terminal 1: the origin on :3000
make demo    # terminal 2: the player on http://127.0.0.1:8080

The page reads /metrics cross-origin, so keep [cors] enabled, as in vod.example.toml.

Packaging from the command line

package parses a local MP4, plans keyframe-aligned segments, and writes separate fragmented MP4 audio and video tracks:

cargo run -- package --input tests/fixtures/h264-aac.mp4 --output target/package-test

Supported input

Progressive and fragmented MP4, M4A, and QuickTime .mov files, with moov first or last. Nothing is decoded or re-encoded, so the codecs must already suit the protocol:

Supported
VideoH.264, HEVC (hvc1/hev1), VP9, AV1
AudioAAC-LC, HE-AAC and HE-AACv2 (explicit signaling), AC-3, E-AC-3, Opus, FLAC
Layoutone video track and any number of audio tracks, or audio only; edit lists of one edit, optionally after one empty edit
Skippedtracks that are not audio or video (timecode, metadata, subtitles)
Rejectedencrypted media, samples in moov mixed with fragments, external data references, more than one sample description per track, other codecs (each error names what was found)

Whether a player can decode a codec is a separate question: HEVC and the Dolby codecs need Safari or a platform decoder, for instance. See TDD 0004 for what was verified where.

Deploying

This page is about getting segmentor running: which way to install it, and ready-made files for Docker, Kubernetes, and systemd. What the settings mean, and what to watch once it runs, is in Operating the origin.

Choosing how to run it

Use it whenFiles
ContainerYou run services under Docker, Compose, or Kubernetes. This is the way most people should run it.deploy/docker-compose.yml, deploy/kubernetes/
Release binaryYou run on a plain Linux host under systemd, or want no container runtime.install.sh, deploy/systemd/
From sourceYou are developing it, or need a platform that has no release build.cargo install --git https://github.com/includeamin/segmentor

The container is the recommended route because the image already carries what the binary needs: it is built on Debian to match its minimal, non-root runtime, so it does not depend on which glibc your host has. Release binaries are dynamically linked against glibc, so they will not run on Alpine or very old distributions.

Before you expose it

segmentor has no TLS and no authentication. Put a reverse proxy or a CDN in front of it that provides both, and let that layer cache media:

  • TLS. For a single host, Caddy does it in two lines, and gets certificates on its own:

    video.example.com {
        reverse_proxy 127.0.0.1:3000
    }
    

    nginx, Envoy, and a cloud load balancer work equally well. Use HTTP/2 or HTTP/3 to viewers.

  • Caching. Init and media segment URLs carry a v query parameter that changes whenever the media does, and are served Cache-Control: public, max-age=31536000, immutable. Playlists and manifests are served with a short max-age (60 s). So a CDN should include the query string in its cache key, and can then cache segments indefinitely without ever serving stale bytes.

  • Access control. If viewers must be authorised, do it at the proxy or CDN, for example with signed URLs or tokens. The origin should be reachable only from that layer.

  • Per-client limits. segmentor limits total connections and concurrent requests, but not per client address. Rate-limit at the proxy if clients are untrusted.

Container

The image is ghcr.io/includeamin/segmentor, for linux/amd64 and linux/arm64. It is tagged X.Y.Z and X.Y for each release, and latest or preview according to the release channel. In production, pin it by digest, since a tag can move, and verify it.

The image expects two mounts:

PathHolds
/etc/vod/vod.tomlThe configuration, read-only. Set server.listen = "0.0.0.0:3000", since inside a container 127.0.0.1 is unreachable.
/srv/vodThe media root that storage.media_root names, read-only.

It runs as a non-root user, needs no writable path, and works with --read-only and --cap-drop=ALL. Logs go to standard output as JSON.

Docker Compose

deploy/docker-compose.yml runs it with the example configuration and a media/ directory next to deploy/:

mkdir -p media && cp /path/to/some-video.mp4 media/sample.mp4
docker compose -f deploy/docker-compose.yml up
ffplay http://127.0.0.1:3000/hls/sample/master.m3u8

Its stop_grace_period is longer than the configuration’s shutdown_delay_ms plus shutdown_grace_ms, so docker compose stop drains streams instead of cutting them. The image has no shell or curl, so there is no container health check; probe /health and /ready from outside.

Kubernetes

deploy/kubernetes/segmentor.yaml is a ConfigMap, a Deployment, a Service, and a PodDisruptionBudget, validated against the Kubernetes 1.30 API schemas. Before you apply it:

  • Media. It mounts a PersistentVolumeClaim named segmentor-media, which you create. Or remove the volume and the [assets.*] table and resolve assets through a mapper, which is the usual choice at scale.
  • Probes. /health is liveness. /ready is readiness and also the startup probe, and it turns 503 when shutdown begins, which is what takes a terminating pod out of the Service before its connections close. terminationGracePeriodSeconds is longer than the configured drain time.
  • Memory. The memory limit must exceed limits.max_index_bytes (512 MiB in the example) plus headroom for segments being streamed. The sample index costs about 40 bytes per sample.
  • Ingress. Put an Ingress or CDN with TLS in front of the Service.

Release binary and systemd

Install the binary

curl -fsSL https://raw.githubusercontent.com/includeamin/segmentor/main/install.sh | sh

Read the script first; it is short. It downloads one release archive and its checksum from GitHub, verifies the checksum, and copies one file into ~/.local/bin (or /usr/local/bin when run as root). It never uses sudo. When the gh tool is installed it also checks the archive’s build provenance, as a warning by default, or as a requirement with --require-attestation. Useful options:

sh install.sh --version v0.4.0 --prefix /usr/local/bin    # pin a release, choose the directory
sh install.sh --dry-run                                   # show what it would do

It supports Linux on x86-64 and arm64 and says so plainly anywhere else. To do the same by hand, download the archive from the releases page, then follow Verifying a release.

Run it as a service

deploy/systemd/segmentor.service runs the binary as an unprivileged segmentor user with the service sandboxed: no capabilities, a read-only filesystem apart from the media path, private /tmp and devices, only network sockets, and no way to gain privileges. systemd-analyze security rates the exposure OK.

sudo useradd --system --no-create-home --shell /usr/sbin/nologin segmentor
sudo install -m 0755 segmentor /usr/local/bin/segmentor
sudo install -d /etc/segmentor && sudo cp vod.toml /etc/segmentor/vod.toml
sudo cp deploy/systemd/segmentor.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now segmentor
journalctl -u segmentor -f

The unit lets the service read /srv/vod; if your storage.media_root is somewhere else, change ReadOnlyPaths. TimeoutStopSec is longer than the configured drain time, and LimitNOFILE is raised above the default connection limit.

Upgrading and rolling back

  • Replace and restart. Swap the image tag or the binary, and restart. The service drains in-flight streams first, within shutdown_grace_ms.
  • Roll one instance at a time behind a load balancer, so /ready takes each out of rotation as it stops.
  • Expect new URLs. The v in media URLs includes a format revision that changes when a release changes the bytes it serves for an unchanged file. After such an upgrade a CDN misses once per segment and then warms again, and players fetch the playlist to get the new URLs. It never serves old bytes under a new version, or the reverse.
  • Roll back the same way. The previous image or binary serves its own URLs, so a rollback is safe and needs no cache purge.

Operating the origin

This guide covers what an operator needs to run segmentor serve behind a load balancer or CDN. Every setting named here is documented in vod.example.toml.

Deployment shape

The service is an HTTP origin. It does not terminate TLS, authenticate viewers, or rate-limit clients. Run it behind a reverse proxy or CDN that does, and cache media responses there: segment URLs are content-versioned and served immutable.

Two protections are built in because a proxy cannot fully provide them:

  • limits.max_concurrent_requests answers 503 with Retry-After: 1 once that many handlers are running. /health, /ready, and /metrics are exempt.

  • limits.response_idle_timeout_ms closes a media response whose client stops reading, and a job slot is held only while a source read is in flight. A stalled client therefore cannot exhaust limits.max_segment_jobs.

  • limits.header_read_timeout_ms (default 10 s) closes a connection that has not delivered a complete request header block in that time, which stops slow-header (slowloris) clients. It also bounds how long an idle keep-alive connection waits for its next request.

  • limits.max_connections (default 10,000) closes connections beyond the cap immediately at accept. Raise the process file-descriptor limit (ulimit -n, or LimitNOFILE under systemd) above this number plus headroom for media file handles; otherwise accept fails with EMFILE before the cap applies (the accept loop then logs accept_failed and backs off for a second).

The service still does not limit connections per client address. Do that on the proxy or load balancer if clients are untrusted.

Probes

PathMeaningUse for
/healthThe process is runningLiveness probe
/ready200 while serving, 503 once shutdown beginsReadiness probe and load-balancer health check
/metricsPrometheus textScraping

Shutdown

The service handles SIGTERM and SIGINT. On either signal it:

  1. flips /ready to 503;
  2. keeps accepting connections for server.shutdown_delay_ms, so a load balancer can notice and drain;
  3. stops accepting new connections and lets in-flight responses finish;
  4. exits after at most server.shutdown_grace_ms more, closing any stream still open.

Set the orchestrator’s termination grace period to at least shutdown_delay_ms + shutdown_grace_ms plus a few seconds. In Kubernetes, a shutdown_delay_ms of 5000 to 10000 typically covers endpoint propagation.

CORS

Browser players need CORS. The [cors] table configures it, and enabled = false turns it off when the proxy or CDN adds the headers instead. Restrict allowed_origins to your player origins in production. allow_credentials = true requires explicit origins, methods, and headers; the configuration is rejected at startup otherwise. The defaults expose Content-Length, Content-Range, Accept-Ranges, ETag, and X-Request-Id to scripts and allow the Range, If-None-Match, and If-Range request headers.

Request tracing

Every response carries X-Request-Id. A caller-supplied ID (1 to 128 characters from letters, digits, -, _, .) is kept, and anything else is replaced. The ID is attached to the request span, so it appears on every log line for that request.

Metrics

MetricTypeNotes
vod_http_requests_total{route,status}counterroute is the route template, so cardinality is fixed
vod_http_request_duration_seconds{route}histogramTime to response headers, not body transfer
vod_http_requests_in_flightgauge
vod_http_response_bytes_totalcounterBytes handed to the response stream
vod_source_read_bytes_totalcounterCompare with response bytes to check read amplification
vod_http_requests_shed_totalcounterRequests refused by max_concurrent_requests
vod_http_connections_opengaugeOpen TCP connections
vod_http_connections_rejected_totalcounterConnections closed at accept because of max_connections
vod_segment_queue_timeouts_totalcounterWaits that exceeded segment_queue_timeout_ms
vod_segment_stream_aborts_{idle,client,error}_totalcounterStreams that ended early, by cause
vod_log_dropped_lines_totalcounterLog records dropped by the lossy queue

Alert on a rising vod_segment_queue_timeouts_total or vod_http_requests_shed_total (undersized limits or overload) and on a non-zero rate of stream_aborts_error.

Memory

Each loaded asset keeps its full sample index in memory, about 40 bytes per sample. limits.max_index_bytes (default 4 GiB) rejects a catalog whose combined indexes exceed it, and startup fails with the measured size. Size the container’s memory limit above that budget plus headroom for in-flight segment reads (stream_chunk_bytes times max_segment_jobs).

Resolving assets from a mapper

By default the catalog is the [assets.*] tables and every asset is loaded before the server accepts traffic. To resolve assets from an external service instead, replace those tables with a resolver (see the commented example in vod.example.toml and the Mapper API reference):

[resolver]
type = "http"

[resolver.http]
base_url = "https://mapper.internal.example.net"
bearer_token_env = "VOD_MAPPER_TOKEN"

Behavior worth knowing before you run it:

  • Assets load on first request. The first viewer of an asset pays the resolve, open, and parse cost (about 100 ms for a one-hour file; more for a remote object). Later requests are served from memory. Warm popular assets with a request after deployment if that matters.
  • Memory is bounded by bytes. Loaded assets are kept in a least-recently-used cache limited by limits.max_index_bytes (about 40 bytes per sample). An evicted asset reloads transparently.
  • Mapper answers are cached for their TTL (clamped by min_ttl_ms and max_ttl_ms), revalidated with If-None-Match, and a missing asset is remembered for negative_ttl_ms.
  • A mapper outage does not stop playback of known assets. An expired answer is served for up to stale_if_error_ms while the mapper is down. A location with an expires_at (a signed URL) is never served past that time. Unknown assets return 503 until the mapper recovers.
  • An upgrade can change URLs. The v in a media URL hashes everything the index was built from (moov, and every moof of a fragmented file) together with a format revision that is bumped whenever a build changes the bytes it serves for an unchanged file (init segment layout, timeline mapping, playlist format). A CDN or browser holding immutable objects from the old build therefore never receives different bytes under an old URL: players fetch the playlist again and get new URLs.
  • Files that are cut off. A fragmented file that ends partway through a fragment, because it is still being written or an upload failed, is refused with a message saying so. Set limits.tolerate_truncated_tail = true to serve the complete fragments before the cut instead; each such load logs truncated_tail_dropped with the bytes and fragments left out. It is off by default because serving less media silently is worse than failing, and it applies only after at least one whole fragment. The asset’s URL version follows the fragments it serves, so a file that grows gets a new version as each fragment completes. A file that is still being appended to is not a supported live source: playlists are built once per load.
  • A changed asset switches at once. When the mapper returns a new version, the old one is dropped. Players holding old versioned URLs get 404 and refetch the playlist.
  • Set readiness_probe_interval_ms if you want /ready to report 503 while the mapper is unreachable, so a load balancer can hold new traffic. /health is unaffected.
  • Startup does not depend on the mapper. The process starts even if the mapper is down.

Remote media

A mapper can return http locations, in which case the server reads the media from that origin with ranged requests. It reads only the headers and metadata to load an asset (moov, plus every moof of a fragmented file), then fetches segment bytes as they are requested. A fragmented file costs about one request per fragment. Without more information they run one after another, because each box’s offset comes from the one before it: about 19 ms per fragment at 15 ms of latency, so an hour of one-second fragments takes over a minute. When the file has a sidx listing where its fragments are, they are fetched limits.metadata_concurrency at a time (default 16) instead, roughly seven times faster in the measurement in TDD 0005; the sidx is only a hint, every fragment is still found by walking its own boxes, and anything that disagrees falls back to the sequential walk. The asset_loaded log line for a fragmented file is followed by fragments_discovered saying which was used. limits.max_fragments (default 20,000) bounds the count, and a file that exceeds it fails to load with a message naming the limit. Because a mapper controls where the server connects, [remote_media] is a security boundary:

  • allowed_hosts must list every origin host; an empty list refuses all remote locations.
  • Locations must be https (allow_insecure_http is for development), carry no credentials, and are never redirected.
  • Names that resolve to loopback, private, link-local, shared, or multicast addresses are refused unless allow_private_addresses = true. Leave it off in production so a mapper cannot point the server at internal services.
  • The origin must support Range and send a strong ETag or a Last-Modified; reads are conditional on it, so a replaced object fails playback instead of mixing versions.
  • Signed URLs are supported: the mapper should set expires_at and re-sign under the same version. The server refreshes ahead of expiry (resolver.http.refresh_margin_ms), rotates the URL on a loaded asset without reloading it, and re-asks the mapper once if the origin rejects a read with 401, 403, or 410. Watch vod_location_rotations_total.

TLS uses the operating system’s trusted roots (the container image carries a CA bundle).

Mapper and registry metrics

MetricTypeNotes
vod_resolver_requests_total{outcome}counterok, unchanged, not_found, unavailable, rejected
vod_resolution_cache_events_total{event}counterhit, miss, revalidate, stale, negative_hit
vod_asset_loads_total{outcome}counterok or failed
vod_asset_load_seconds_totalcounterDivide by loads for the mean load time
vod_location_rotations_totalcounterSigned URLs replaced in place on loaded assets
vod_registry_coalesced_waiters_totalcounterRequests that shared another request’s resolve or load
vod_loaded_assets, vod_loaded_bytesgaugeWhat is in memory now

Alert on a rising unavailable or rejected count (mapper trouble or a bad answer), on stale events (the mapper is down and old data is being served), and on failed loads.

Container image

The Dockerfile is a four-stage build optimized for Rust:

  • Dependency caching. cargo-chef compiles dependencies from a recipe derived from Cargo.toml and Cargo.lock, so a source-only change rebuilds only the application. BuildKit cache mounts keep the cargo registry between builds.
  • Reproducibility. Builds use --locked. Pin RUST_IMAGE and RUNTIME_IMAGE to a version and digest for release builds (see the header of the file).
  • Small, hardened runtime. The final stage is distroless/cc-debian12:nonroot: glibc only, no shell or package manager, running as a non-root user. The binary is dynamically linked against glibc rather than musl on purpose, because musl’s allocator is slower under this multi-threaded workload.
  • Release profile. [profile.release] uses thin LTO, one codegen unit, and stripped debug info.
  • Signals. The entrypoint is in exec form so the service is PID 1 and receives SIGTERM directly.
  • Build context. .dockerignore admits only the manifests and src/, so unrelated changes do not invalidate layers.
docker build -t segmentor --build-arg VERSION=0.1.0 --build-arg REVISION=$(git rev-parse HEAD) .
docker run --rm -p 3000:3000 --read-only --cap-drop=ALL \
  -v "$PWD/vod.toml:/etc/vod/vod.toml:ro" -v "$PWD/media:/srv/vod:ro" segmentor

Set server.listen = "0.0.0.0:3000" and storage.media_root = "/srv/vod" in the mounted configuration. The build stage installs cmake and a C toolchain because the TLS provider (aws-lc-sys) compiles C code. Use docker stop --time (or the orchestrator’s termination grace period) above shutdown_delay_ms + shutdown_grace_ms. The image has no HEALTHCHECK because it contains no shell or HTTP client; probe /health and /ready from the orchestrator.

Performance budgets

The budgets in TDD 0001 are measured by a harness that runs the real server and the real packaging code. This page records how to run it, what it measures, the current results, and what the results do and do not show.

Running it

make bench            # print the results
make bench-enforce    # exit non-zero if a budget is missed

The harness is benches/budgets.rs (harness = false, no benchmarking dependency). It starts the server in-process on a loopback port through the hidden benchmarking module and drives it with a small keep-alive HTTP client. cargo test skips it, so it never runs as part of the test suite.

On the first run it uses FFmpeg to synthesize a 60-minute H.264/AAC asset in target/bench/ by stream-copying the committed 3-second fixture 1,200 times (no re-encoding, -use_editlist 0). Without FFmpeg the long-asset checks are skipped.

VariableDefaultMeaning
BUDGET_STREAMS1000Concurrent streaming clients
BUDGET_SECONDS10Duration of the streaming test
BUDGET_WORKERS4Tokio worker threads, mirroring the four-core reference host

What is measured

MeasurementBudgetHow
Warm load (open, parse, plan, init segments, render) of a 60-minute assetp95 < 250 ms11 repeated loads after one discarded cold load
Warm 6 s segment header generationp95 < 10 ms3,000 prepare_media_segment calls across the timeline
Cached HLS master, HLS media, and DASH responsesp95 < 2 ms3,000 sequential requests each over one keep-alive connection
Source bytes read beyond the payload<= 256 KiB per segmentvod_source_read_bytes_total against bytes sent, over 300 segments
Origin 5xx and connection errors with 1,000 sustained streams< 0.1 %1,000 connections each fetching random segments for the test duration
Extra resident memory per streaming connection<= 512 KiBPeak RSS minus idle RSS, divided by the stream count

It also reports, without a budget: full-segment transfer latency, throughput, requests shed or rejected, and the scheduling delay of a 1 ms canary task.

Results

Recorded 2026-09-20 with make bench on a laptop: Intel Core i7-8550U (4 cores, 8 threads, 1.8 GHz base), 15 GiB RAM, Linux, warm page cache, four Tokio workers, one release build.

MeasurementBudgetResult
Warm load, 60-minute assetp95 < 250 msp50 85 ms, p95 121 ms
Cold first load-110 ms
6 s header generationp95 < 10 msp95 0.006 ms
HLS master / HLS media / DASH (loopback, includes client)p95 < 2 msp95 0.071 / 0.075 / 0.073 ms
Full 6 s segment over loopback-p50 2.4 ms, p95 3.0 ms
Source bytes beyond payload<= 256 KiB0
1,000 sustained streams< 0.1 % errors0 of 6,302 requests; 598 req/s, 116 MiB/s
Extra memory per streaming connection<= 512 KiB66 KiB (includes the benchmark’s own client buffers)

The figures were refreshed after the async source and registry refactor; the load path now fetches metadata through Metadata and assembles on the blocking pool, which costs a few tens of milliseconds more than the earlier synchronous path but stays well inside the budget. The 60-minute asset has 600 segments and an index of 8.6 MiB (about 276,000 samples).

A finding the harness caught

The first run failed the startup budget by roughly ten times: a warm load of the 60-minute asset took about 2.4 seconds. The cause was that the mp4 crate reads every sample-table entry with its own read call, and the parser handed it an unbuffered File, so parsing issued millions of system calls (the run also showed high system CPU). LocalMediaSource::parser_file now returns a 256 KiB BufReader. The same load takes about 70 ms, a 34-fold improvement, and initialization-segment generation, which parses again per track, benefits equally. Sample-table entries are still read one at a time in user space, so a future optimization could feed the crate an in-memory ftyp and moov instead.

Limits of these results

  • One host class. The budgets name a documented four-core x86-64 host with local SSD. This laptop is comparable but not identical; record the CPU, storage, and command when comparing runs, and run on the reference host before declaring the budgets met for release.
  • Loopback, shared runtime. Client and server run in one process on one runtime, so client work competes with the server for the four workers. That makes the results conservative for throughput and latency, and it means the playlist latencies include client parsing rather than pure server time.
  • One asset, warm cache. All streams read the same file from the page cache. A large catalog on cold storage will be I/O-bound, which the source-read counters will show.
  • Memory is a coarse estimate. Peak resident size divided by streams includes the benchmark client’s buffers and allocator behavior. The design bound is two channel items of stream_chunk_bytes (512 KiB at defaults) per stream.
  • Event-loop blocking is not directly measured. The requirement that no filesystem operation on a Tokio worker exceeds 1 ms is met structurally: source reads and header construction run on the blocking pool. The canary’s scheduling delay is reported for information only, because CPU saturation from the client on the same runtime inflates it (p99 about 1.6 ms, one outlier of about 13 ms).
  • No packet-level tail under sustained overload. The streaming test runs at the configured concurrency for ten seconds and does not probe behavior past the limits; see the shedding and connection-cap tests for that.

Regressions

make bench-enforce exits non-zero when a budget is missed. The Benchmarks workflow runs it on demand from the Actions tab (workflow_dispatch), because shared CI runners are too noisy to gate every pull request on latency budgets.

Protocol conformance

The service’s HLS and DASH output is checked by an automated black-box suite, and the remaining vendor and browser checks are listed with how to run them.

The automated suite

tests/conformance.rs starts the real binary against every fixture below and audits what a player would fetch. Run it with make conformance (it also runs under make test and in CI).

Fixtures: the main H.264/AAC file, the same with moov after mdat, video only, 44.1 kHz stereo audio, and variable frame timing; FFmpeg’s default edit lists and a delayed audio track; two audio tracks; non-square pixels with tagged colour; a QuickTime file; HEVC, VP9, AV1; AC-3, E-AC-3, Opus, and FLAC audio; and audio-only files with one and two tracks. Every reassembled track is decoded by FFmpeg without errors and its packet count compared with the source.

HLS (RFC 8216)

  • Playlist starts with #EXTM3U; the protocol version is at least 6, which fMP4 with EXT-X-MAP requires.
  • The variant has BANDWIDTH not below AVERAGE-BANDWIDTH, a codec string, and a resolution.
  • An AUDIO group reference on the variant matches exactly one EXT-X-MEDIA rendition, and a rendition without a reference is rejected.
  • Media playlists are VOD, INDEPENDENT-SEGMENTS, end with ENDLIST, and have an EXT-X-MAP.
  • Every EXTINF rounded to whole seconds does not exceed EXT-X-TARGETDURATION.
  • Every URI in every playlist resolves and returns 200.

DASH (ISO/IEC 23009-1)

  • The manifest has the MPD namespace, type="static", a DASH profile, and valid mediaPresentationDuration and minBufferTime.
  • Each Representation has bandwidth and codecs and a SegmentTemplate with a SegmentTimeline.
  • $RepresentationID$ and $Number$ substitutions resolve to real objects.
  • The timeline does not exceed the presentation duration, and the longest track equals it.

Media bytes (ISO BMFF, CMAF-style layout)

  • Init segments have ftyp, moov, exactly one trak, mvex/trex, and empty sample tables.
  • Media segments are [styp] moof mdat, every box size tiles its parent exactly, and tfhd uses default-base-is-moof.
  • The trun data offset lands exactly on the first payload byte, and the sample sizes sum to the mdat payload.
  • mfhd sequence numbers run 1, 2, 3 …; tfdt values are contiguous, each starting where the previous segment ended.
  • Video segments begin with a sync sample.
  • The duration declared in the playlist or timeline matches the sum of sample durations in the fragment, within one millisecond of rounding.
  • HLS and DASH serve byte-identical init and media segments for every track.

Transport

Byte ranges (bytes=a-b, suffix bytes=-n), Accept-Ranges, ETag, If-None-Match, stale If-Range, and immutable caching on media objects.

Decoding

When FFprobe and FFmpeg are installed, each track is reassembled (init plus segments), decoded with ffmpeg -v error with no errors, and its packet count is compared with the source file’s.

The suite was mutation-checked: making the fragment sequence numbers start at 2 fails it immediately.

What is not covered

The suite verifies structural rules the code can be held to. It is not a certification.

CheckStatusHow to run it
Apple HLS validation (mediastreamvalidator, hlsreport)Not runRequires macOS and Apple’s HTTP Live Streaming Tools. Point the validator at http://<host>/hls/<asset>/master.m3u8 before a release
DASH-IF conformance (DASH-IF-Conformance tool or web validator)Not runNeeds Java or the hosted validator. Run it on a served MPD before a release
CMAF (ISO/IEC 23000-19) profile conformanceNot claimedThe layout is CMAF-style but no CMAF validator has been run. Do not claim conformance
Browser playback with hls.js and dash.js (start, seek, play)Not implementedNeeds Node, npm, and a browser driver such as Playwright. Neither npm nor a driver was available where this suite was written
Device and player matrix (Safari, tvOS, Android ExoPlayer, smart TVs)Not coveredManual

Until the vendor validators and browser tests are run and their output recorded here, treat the output as structurally sound and cross-checked but not certified.

Mapper API reference

This page is for people who write a mapper: the service that tells segmentor where the media for an asset ID lives. It is a self-contained reference. The reasoning behind it is in TDD 0002.

A mapper answers one question: where is asset X, and which version of it is current? It never sees or returns media bytes. Playback authorization is not its job either; put viewer checks in a front proxy.

Endpoints

MethodPathPurpose
GET/v1/assets/{asset_id}Resolve one asset
GET/v1/healthOptional reachability probe

{asset_id} is 1 to 128 ASCII letters, digits, -, or _. The server rejects any other ID before contacting the mapper, so no escaping is needed. The mapper must ignore request headers it does not know, and the server ignores response fields it does not know, so either side can add fields without breaking the other.

Resolve an asset

GET /v1/assets/big-buck-bunny
Accept: application/json
Authorization: Bearer <token>          (if configured)
If-None-Match: "2026-09-18T10:22:31Z#7"  (on revalidation)
X-Request-Id: 18d6d3516a4d8c6c-2
User-Agent: segmentor/0.1.0

200 OK

Content-Type: application/json. A file on the server’s media root:

{
  "asset_id": "big-buck-bunny",
  "version": "2026-09-18T10:22:31Z#7",
  "ttl_seconds": 300,
  "location": { "type": "file", "path": "movies/big-buck-bunny.mp4" }
}

A file on an HTTP origin:

{
  "asset_id": "big-buck-bunny",
  "version": "etag-9f2c",
  "ttl_seconds": 300,
  "expires_at": "2026-09-19T12:00:00Z",
  "location": { "type": "http", "url": "https://origin.example.net/movies/big-buck-bunny.mp4?sig=..." }
}
FieldRequiredRules
asset_idYesMust equal the requested ID exactly
versionYes1 to 256 visible ASCII characters (no spaces). Any change to the media must change it; equal versions are assumed to be identical media
ttl_secondsNoHow long the server may reuse this answer. Falls back to Cache-Control: max-age, then to the server’s default, and is clamped to the server’s minimum and maximum
expires_atNoRFC 3339 deadline after which the location itself is dead, for example a pre-signed URL. The answer is never reused past it, even when the mapper is down
location.typeYesfile or http. Anything else is rejected
location.pathFor fileRelative to storage.media_root; no leading /, no . or .. components, no NUL, at most 4096 bytes
location.urlFor httpSee Remote locations
subtitlesNoSidecar WebVTT files; see Subtitles

304 Not Modified

Send this when If-None-Match names the current version. The body is empty. A Cache-Control: max-age=N header sets the new reuse window. The server only sends If-None-Match when it already holds a usable answer, and never when the previous location has passed its expires_at, so a 304 can only be an answer to a valid question.

Errors

StatusMeaningWhat the server does
404 or 410The asset does not exist (or no longer does)Serves 404 to players, caches the absence briefly, and drops any loaded copy
401 or 403The server’s credentials are wrongServes 502, logs at error
429The mapper is shedding loadRetries after Retry-After (capped at one second), then serves 503
5xx, timeout, connection failureThe mapper is unhealthyRetries with a short backoff, then serves 503, or serves the previous answer if it is still within the stale window
200 with an invalid body, wrong asset_id, bad version, unknown location type, or a policy violationMalformed answerServes 502 and never caches the answer as valid
Any other 4xxContract violationServes 502

Behavior depends only on the HTTP status, so error bodies are informational. {"error": {"code": "...", "message": "..."}} is a good shape.

Health

GET /v1/health returning any 2xx means healthy. It is used only when the server’s optional readiness probe is enabled, in which case the server reports itself not ready while the mapper is unreachable. Mappers without this endpoint can leave the probe disabled.

Remote locations

An http location makes the server fetch media from a URL the mapper chose, so the server applies the operator’s policy before any request:

  • the scheme must be https (or http if the operator enabled it for development);
  • the host must be in the server’s remote_media.allowed_hosts list, compared exactly and case-insensitively;
  • credentials in the URL (user:pass@) are refused; put access control in the query string (a signature) instead;
  • literal IP addresses, and names that resolve to loopback, private, link-local, shared, or multicast addresses, are refused unless the operator allowed private addresses;
  • redirects are never followed.

The origin must:

  • honor Range requests with 206 and a correct Content-Range;
  • send a strong ETag, or a Last-Modified, on the response. The server sends it back as If-Range on every read, so an object that changes while it is being read fails the read instead of mixing two versions. A weak ETag alone is refused.

Signed URLs

Signed query parameters are treated as secrets: they are not logged at info and never appear in error responses. The server handles URL rotation in three ways, so a mapper only has to issue a fresh signature when asked:

  • Refresh ahead. When an answer has expires_at, the server re-asks the mapper before the deadline (resolver.http.refresh_margin_ms, default 30 seconds early, but never before half the remaining lifetime has passed). The mapper is asked at the next request after that point, so playback that is in progress keeps refreshing itself.
  • In-place rotation. If the new answer has the same version and names the same object (same scheme, host, port, and path, differing only in the query string), the server keeps the loaded asset and simply uses the new URL from the next read on. Nothing is reparsed, and streams already in flight pick up the new signature on their next chunk. Any other change (a different version, path, or host) reloads the asset.
  • Recovery on rejection. If the origin answers 401, 403, or 410 to a read (an expired or revoked signature, or clock skew), the server asks the mapper once for a fresh answer, without an If-None-Match, and retries the read. Concurrent rejections share one lookup. If the mapper cannot provide a working location, the response ends in an error rather than retrying forever. This recovery is disabled while an asset is first loading; a rejected location at that point is a 502.

So: set expires_at on anything signed, keep the same version when you only re-sign, and answer unconditional requests (no If-None-Match) with a full body and a new signature. A 304 is only appropriate while the current signature is still valid.

Subtitles

An answer may attach WebVTT subtitle files to the asset:

{
  "asset_id": "movie",
  "version": "2026-09-21-a",
  "location": { "type": "file", "path": "movie.mp4" },
  "subtitles": [
    { "language": "en", "label": "English", "default": true,
      "location": { "type": "file", "path": "subs/movie.en.vtt" } },
    { "language": "fr", "label": "Français", "forced": false,
      "location": { "type": "http", "url": "https://origin.example.net/subs/movie.fr.vtt" } }
  ]
}
FieldRequiredRules
languageYesA BCP 47 tag of letters, digits, and hyphens, starting with a letter, at most 35 characters. Unique within the asset, ignoring case. It appears in the URLs
labelNoWhat a player shows the viewer. Defaults to the language. At most 128 bytes, no control characters
defaultNoThe player selects this one unless the viewer chose otherwise. At most one entry may set it
forcedNoThe track is meant to be shown even when the viewer has not asked for subtitles
locationYesA file or http location with the same rules as the media’s, including the [remote_media] policy. An http origin must support ranged requests, as media origins do

The server fetches each file when the asset loads and keeps it in memory, so playback never touches the subtitle origin. A file must be UTF-8, must begin with WEBVTT, and must have readable cue timing lines. It is limited by limits.max_subtitle_bytes (2 MiB), limits.max_subtitles_total_bytes (8 MiB per asset), and limits.max_subtitles (16). One bad file fails the whole asset with the language named, so a viewer never gets a language that is silently missing.

Cue times are read as times on the source file’s own clock, the one its edit lists describe. Packaging can move a file onto a later timeline so that no timestamp is negative (this is what an edit list that trims encoder delay does, and it is typically a few tens of milliseconds), and the server adds that same offset to every cue so they stay in step with the picture. A video that simply starts late, through a leading empty edit, is not an offset: the cues were written against a clock that already includes that gap, so they are left alone. A fragmented file’s timeline starts at zero and cues are not moved. Nothing else in the file changes.

Change version when a subtitle file changes. The server reloads an asset only when its version or location changes, so an edited caption under an unchanged version is not picked up until the asset is evicted.

The HLS master playlist gains an #EXT-X-MEDIA:TYPE=SUBTITLES entry per file, served from /hls/{asset}/subtitles/{language}/index.m3u8, and the DASH manifest gains a text adaptation set. Both point at /{hls|dash}/{asset}/subtitles/{language}/sub.vtt?v={version}.

What a version means to the server

The server keeps one loaded copy per asset, keyed by (asset_id, version). A different version, or the same version at a different location (a rotated signed URL), makes it reload from the new location. There is no grace period: players holding URLs from the old version get 404 and recover by fetching the playlist again. Change version only when the media actually changes.

Try it

A minimal mapper for local development, using only Python’s standard library:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

CATALOG = {"movie": {"version": "v1", "path": "movies/movie.mp4"}}

class Mapper(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/v1/health":
            self.send_response(200); self.end_headers(); return
        asset = CATALOG.get(self.path.rsplit("/", 1)[-1]) if self.path.startswith("/v1/assets/") else None
        if asset is None:
            self.send_response(404); self.end_headers(); return
        body = json.dumps({
            "asset_id": self.path.rsplit("/", 1)[-1],
            "version": asset["version"],
            "ttl_seconds": 60,
            "location": {"type": "file", "path": asset["path"]},
        }).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("127.0.0.1", 9911), Mapper).serve_forever()

Point the server at it (allow_insecure_mapper is for development only; production mappers use https):

[storage]
media_root = "/srv/vod"

[resolver]
type = "http"

[resolver.http]
base_url = "http://127.0.0.1:9911"
allow_insecure_mapper = true

Then curl http://127.0.0.1:3000/hls/movie/master.m3u8.

Checklist for mapper authors

  • The response asset_id echoes the request.
  • version changes whenever the media changes, and only then.
  • Removed assets answer 404 or 410, not 200.
  • Answers are small (the server’s default limit is 16 KiB).
  • The mapper answers quickly: the server’s default per-request timeout is two seconds, with two retries.
  • version also changes when a subtitle file changes.
  • expires_at is set for anything signed, and re-signing keeps the same version.
  • The mapper is reachable over https in production and requires the bearer token.

Releasing

Versions are tagged automatically after every merge, and a release is published by hand when you want one. Both follow Semantic Versioning and are driven by Conventional Commits.

The commit convention

Every commit that lands on main needs a subject of the form type(scope): summary. With squash merges (recommended) the pull request title becomes that subject, and the PR title workflow rejects titles that do not follow it.

TypeEffect on the version
featMinor bump (1.2.0 to 1.3.0)
fix, perfPatch bump (1.2.0 to 1.2.1)
Any type with ! (refactor(api)!: ...), or a BREAKING CHANGE: line in the bodyMajor bump (1.2.0 to 2.0.0)
docs, refactor, test, build, ci, chore, style, revertNo release by themselves

The highest level in a merge wins. While the version is 0.x, a breaking change bumps the minor number instead, so 0.4.0 becomes 0.5.0; the first breaking change after 1.0.0 is a major bump. Commits that do not follow the convention never trigger a release but still appear in the changelog under “Other changes”.

Example, for a change that needs a note:

feat(resolver)!: require a version in mapper answers

BREAKING CHANGE: answers without a `version` are now rejected.

Automatic tags: the Tag release workflow

After the CI workflow succeeds on main, .github/workflows/tag.yml runs .github/scripts/next-version.sh for the tested commit:

  1. It finds the newest vX.Y.Z tag reachable from that commit. Pre-release tags such as v1.0.0-rc.1 are ignored.
  2. It reads the non-merge commits since that tag and picks the highest level from the table above.
  3. If there is a releasable change it creates an annotated tag vX.Y.Z on the tested commit and pushes it. Otherwise it records why nothing was tagged in the run summary.

The very first tag uses the version in Cargo.toml (currently 0.1.0), so the history starts from a known point. After that Cargo.toml is not edited on main; the tag is the source of truth, and the release build stamps the tag’s version into the binary (see below). This avoids a commit-back step that branch protection would block.

Tagging is sequential (one run at a time) and idempotent: re-running a workflow never creates a second tag for the same commit or version. It creates a tag only, not a GitHub release, so it does not need the release assets.

To preview what the next tag would be, run the script locally:

.github/scripts/next-version.sh

Publishing a release: the Release workflow

Run Actions → Release → Run workflow and choose:

InputMeaning
tagThe version tag to release, for example v1.3.0. Leave it empty for the newest tag
channellatest marks the release as the latest one. preview marks it as a pre-release, and it will not become “latest”

The workflow then runs these jobs:

  1. Prepare. Builds the changelog for that tag from the commits since the previous version tag, grouped into Breaking changes, Features, Bug fixes, Performance, Documentation, Refactoring, Build and CI, Tests, and Other changes, with links to each commit and to the full comparison.
  2. Build, once for x86_64-unknown-linux-gnu and once for aarch64-unknown-linux-gnu, each on a native runner. It checks out the tag, stamps its version into Cargo.toml and Cargo.lock for the build only, builds segmentor in release mode with --locked, and packages segmentor-vX.Y.Z-<target>.tar.gz (the binary, LICENSE-MIT, LICENSE-APACHE, README.md, and vod.example.toml) with a SHA-256 file.
  3. Publish. Attests the build provenance of each archive, then creates the GitHub release, or updates it if it already exists (notes, title, and channel), and uploads the assets.
  4. Container. Runs the Container image workflow: builds the image natively for linux/amd64 and linux/arm64, publishes one multi-architecture image to ghcr.io/<owner>/segmentor, signs it with cosign, and attests its provenance. It is tagged X.Y.Z and X.Y, plus latest or preview according to the channel. That workflow can also be run by hand for an existing tag.

Running the release again for the same tag is safe, so a preview can be promoted to latest by re-running with channel = latest. A preview release is titled vX.Y.Z (preview).

Verifying a release

Each archive and the image carry evidence of where they were built, so a download can be checked rather than trusted.

# A release archive: the checksum, then the build provenance from GitHub.
sha256sum --check segmentor-v0.4.0-x86_64-unknown-linux-gnu.tar.gz.sha256
gh attestation verify segmentor-v0.4.0-x86_64-unknown-linux-gnu.tar.gz --repo includeamin/segmentor

# The container image: the keyless signature, which names this repository's workflow.
cosign verify ghcr.io/includeamin/segmentor:0.4.0 \
  --certificate-identity-regexp '^https://github.com/includeamin/segmentor/\.github/workflows/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

# Or the GitHub attestation of the image.
gh attestation verify oci://ghcr.io/includeamin/segmentor:0.4.0 --repo includeamin/segmentor

Pin the image by digest (ghcr.io/includeamin/segmentor@sha256:...) in anything that matters, since a tag can move.

Before the first release from a new repository

  • Make the repository public first. Build attestations are available for public repositories on every plan, but need an Enterprise plan for private ones, and the arm64 runners are free only for public repositories.
  • Publish the container package. GHCR creates a new package as private the first time it is pushed to. Under the package’s settings, set its visibility to public and link it to the repository.
  • Try the pipeline once on a preview tag, watching each job. The steps that are plain shell have been run locally, but the workflow as a whole can only run on GitHub.

Local checks

The scripts are plain Bash and have their own tests, which run in make ci:

make test-scripts

They build throwaway git repositories and check the version bump for every commit type, the ignored pre-release tags, merge commits, the changelog sections, and the version stamping.

Limits worth knowing

  • The workflows use only GITHUB_TOKEN and the marketplace actions they name. Tags pushed with that token do not start other workflows, which is why the release is a separate manual run, and why the image is published by a workflow the release calls rather than by a release event.
  • The Linux binaries are dynamically linked against glibc. A static musl build is not provided, because the TLS provider compiles C code that makes that build harder; the container image is built on Debian to match its distroless runtime.
  • Nothing is published to crates.io by the workflows. Publishing a crate cannot be undone, only yanked, so it is a manual cargo publish after cargo package has been checked.
  • macOS and Windows binaries are not built.
  • The tag, release, and container workflows have not been run on GitHub yet; the scripts and shell steps they call have been run locally.

Technical design documents

Technical design documents explain a proposed subsystem before or during implementation. They may evolve as experiments reveal new constraints.

Documents

IDTitleStatus
0001On-demand MP4 packaging coreAccepted; verification pending
0002Asset mapper interfaceAccepted; implemented
0003Production-grade HTTP APIAccepted; implemented
0004Broader MP4 input supportAccepted; implemented
0005Fragmented MP4 inputAccepted; implemented
0006Trick play, subtitles, and adaptive renditionsDraft

Workflow

  1. Copy template.md to the next zero-padded number.
  2. Keep the document in Draft while major questions remain.
  3. Record architectural choices as ADRs and link them from the design.
  4. Change the status to Accepted before implementation becomes the reference behavior.
  5. Use Superseded when a replacement design is accepted, and link both documents.

TDD 0001: On-demand MP4 packaging core

  • Status: Accepted
  • Created: 2026-09-10
  • Updated: 2026-09-10
  • Related ADRs: ADR 0001

Implementation status

This design is accepted, but not every capability is implemented. Status terms in this document have precise meanings:

  • Implemented: present in the current code and covered by automated tests.
  • Pending: required by this design before the service is production-ready.
  • Deferred: intentionally outside this design or postponed to a later design.
CapabilityStatusNotes
Local positioned readsImplementedLinux read_exact_at; checked source bounds
Progressive MP4 sample indexingImplementedH.264/AAC sample tables validated against FFprobe
Keyframe-aligned segment planningImplementedOne video track and optional audio track
Separate-track fMP4 generationImplementedInit segments cached; media segments generated on request
HLS VODImplementedMaster and media playlists with fMP4 segments
TOML asset catalogImplementedLoaded and validated at startup
Structured non-blocking logsImplementedConfigurable level/format; bounded lossy queue
Direct bounded HTTP range streamingImplementedHeader plus coalesced source ranges; 256 KiB default chunks
Enforceable parser/resource limitsImplementedValidated TOML limits cover source, metadata, tracks, samples, segments, queues, and headers
Source mutation detectionImplementedFilesystem identity plus pre/post parse moov SHA-256
Explicit encryption rejectionImplementedRaw preflight also rejects external references and multiple descriptions. Edit lists are applied since TDD 0004
Runtime cache invalidation/reloadImplemented for mapper-resolved assetsSee TDD 0002. Static catalog assets are still immutable for the process lifetime
Remote HTTP sources and asset mapping serviceImplementedSee TDD 0002 and the mapper API
DASH VODImplementedStatic MPD reuses separate-track fMP4 artifacts
Automated HLS/DASH decode suiteImplementedFFmpeg consumes both protocols over an ephemeral HTTP server
Structural HLS/DASH/fMP4 conformance suiteImplementedtests/conformance.rs; see conformance
Formal HLS/DASH conformance toolsPendingApple validator and DASH-IF tool not run; required before claiming protocol/CMAF conformance
Browser playback suitePendinghls.js/dash.js Playwright coverage is not implemented
Seeded media pipeline fuzzingImplementedOne target covers preflight, parsing, planning, init writing, and fragment preparation
Async segment streaming with slow-client protectionImplementedJob slots are held per source read, not per response; response_idle_timeout_ms drops stalled clients
Version-enforced immutable URLsImplementedv is required on init and media URLs; missing or stale versions return 404 with no-store
Configurable CORSImplemented[cors] table; exposes range/ETag headers and covers error responses
Readiness, SIGTERM drain, request sheddingImplemented/ready, shutdown_delay_ms/shutdown_grace_ms, max_concurrent_requests
Request metrics and request IDsImplementedPrometheus counters/histogram with fixed cardinality; X-Request-Id on every response
Precomputed playlists and index memory budgetImplementedRendered at load; max_index_bytes bounds total index memory

Summary

Build an HTTP origin that reads existing MP4 files and packages their encoded samples on demand as MPEG-DASH and HLS. The first version will transmux compatible audio and video into fragmented MP4 segments; it will not decode or re-encode media.

The hot path should parse and cache source metadata once, calculate keyframe-aligned segment boundaries, generate small container headers, and stream sample byte ranges from the source. This makes work proportional to the requested segment instead of the duration of the asset.

Terminology

  • Packaging or transmuxing: changing manifests and containers without changing encoded audio or video.
  • Transcoding: decoding and re-encoding media. This is CPU-intensive and outside the first core.
  • Progressive MP4: a typical MP4 with movie metadata in moov and encoded samples in one or more mdat boxes.
  • Fragmented MP4: an initialization segment plus media fragments containing moof metadata and mdat sample data.
  • CMAF: a constrained fragmented MP4 media format intended to improve interoperability across streaming protocols.
  • GOP: a group of pictures beginning with a random-access video sample, commonly called a keyframe.

Goals

  • Package local progressive MP4 files into on-demand DASH and HLS outputs.
  • Share one fragmented MP4 segmenter between DASH and HLS.
  • Avoid decoding, encoding, and whole-file copies on the request path.
  • Align video segment starts to random-access samples.
  • Preserve decode timestamps, presentation timestamps, and composition offsets.
  • Bound memory, metadata size, sample count, and source reads for untrusted files.
  • Cache immutable metadata and produce deterministic, CDN-cacheable responses.
  • Keep protocol manifest generation separate from ISO Base Media File Format logic.
  • Establish correctness and performance evidence before declaring the service production-ready.

Non-goals for the first core

  • Transcoding or changing codecs, resolution, bitrate, frame rate, or GOP layout.
  • Creating an adaptive bitrate ladder from one source file.
  • DRM, HLS encryption, subtitles, ad insertion, clipping, or concatenation.
  • Remote HTTP source files (delivered later by TDD 0002).
  • Live ingest, simulated live playback, and LL-HLS. This project is currently VOD-only, with no commitment to add live streaming later.
  • MPEG-TS output. Initial HLS uses fragmented MP4.
  • YAML configuration and configuration hot reload.

Input contract

The first implementation accepts a seekable local MP4 with one supported video track and, optionally, one supported audio track. Initial codec support should be deliberately narrow:

  • H.264/AVC video with its decoder configuration present in the sample entry.
  • AAC-LC audio with its decoder configuration present in the sample entry.

Packaging cannot repair incompatible media. Input must already have suitable codecs and random-access points. Separate files used as an adaptive set must have aligned content, compatible durations, and matching GOP boundaries; that validation belongs after single-file packaging works.

Files with unsupported edit lists, malformed timing tables, external data references, encryption, or unsupported sample descriptions must fail with a precise error rather than produce questionable output. The rejection matrix is:

Input conditionRequired behaviorStatus
Fragmented MP4 inputIndex from the moof boxesImplemented (TDD 0005)
Codec other than H.264/AAC-LCReject as unsupported mediaImplemented
Missing H.264 SPS/PPSReject as unsupported mediaImplemented
More than one video trackReject during segment planningImplemented. Several audio tracks are supported since TDD 0004
Subtitle trackReject as unsupported mediaImplemented. Tracks that are not audio or video (timecode, timed metadata) are skipped since TDD 0004
Missing/inconsistent sample tablesReject as invalid mediaImplemented for parsed tables
Sample byte range outside sourceReject as invalid mediaImplemented
Edit list of one edit, optionally after an empty editApply it to the sample timelineImplemented (TDD 0004)
Any other edit list shapeReject as unsupported mediaImplemented (TDD 0004)
Encrypted encv/enca sample entryRejectImplemented
External data referenceRejectImplemented
More than one sample description per selected trackRejectImplemented
Source changes while parsingDiscard parse result and fail startupImplemented

Unsupported media detected while loading the startup catalog prevents the server from becoming ready. A malformed configured asset is not skipped silently.

How an MP4 becomes streamable

A progressive MP4 usually stores global and track metadata in moov. Per-track sample tables describe where encoded samples are located and when they decode and display. Important data includes:

  • movie and track timescales and durations;
  • track type and codec configuration;
  • decoding-time deltas from stts;
  • composition offsets from ctts;
  • random-access samples from stss;
  • sample sizes from stsz or stz2;
  • sample-to-chunk mapping from stsc;
  • chunk offsets from stco or co64.

The parser expands or provides indexed access to these tables and creates an immutable MediaIndex:

MediaIndex
  source identity and length
  movie timescale and duration
  tracks[]
    id, kind, timescale, duration
    codec and decoder configuration
    samples[]
      byte offset and size
      decode timestamp and duration
      composition offset
      random-access flag

The index contains metadata, not sample payloads. It is safe to share between requests after construction.

Core component boundaries

flowchart LR
    HTTP[HTTP router] --> Catalog[Asset resolver]
    Catalog --> Cache[Metadata cache]
    Cache --> Source[Media source]
    Cache --> Parser[MP4 parser]
    Parser --> Index[Immutable media index]
    Index --> Planner[Segment planner]
    Planner --> Manifest[DASH or HLS manifest]
    Planner --> Writer[fMP4 fragment writer]
    Source --> Writer
    Writer --> Body[Streaming HTTP body]

Media source

Expose the minimum random-access operations needed by the parser and writer:

length() -> bytes
read_range(offset, length) -> bytes or stream
identity() -> stable cache key

The local implementation should use positioned reads so concurrent requests do not share a mutable seek cursor. A later HTTP implementation can satisfy the same contract with range requests.

MP4 parser

Parse box headers defensively, validate every offset and size against the source length, and extract only metadata needed for packaging. The bounded raw preflight rejects unsupported edit lists, encrypted entries, external data references, and multiple sample descriptions before mp4 crate parsing. The adapter then enforces track/sample limits, expanded table counts, checked arithmetic, source ranges, and pre/post parse source identity.

The parser should initially be backed by an established Rust ISO BMFF crate if a prototype proves that it exposes exact sample offsets and timing without copying payload data. We should compare candidate crates with a small fixture corpus before adopting one. Writing a complete parser is not an initial goal.

Segment planner

Given a target duration, the planner creates a deterministic timeline:

  1. Choose each video boundary at a random-access sample near the target duration.
  2. Never begin a video segment on a dependent frame.
  3. Select audio samples whose decode-time interval corresponds to the video interval.
  4. Preserve exact per-track timescales; use checked integer rescaling only at boundaries.
  5. Record actual durations for manifests rather than assuming every segment has the target duration.

The target duration is a policy, not a guarantee. Keyframe placement determines valid video boundaries. For adaptive bitrate output, all representations must use a shared boundary timeline.

Fragment writer

The writer emits:

  • an initialization segment containing ftyp and a fragment-ready moov with track metadata and mvex/trex defaults;
  • one media segment per request, containing an optional styp, a generated moof, and an mdat containing selected encoded samples.

The moof carries sequence, track, base decode time, sample duration, size, flags, and composition offset information through mfhd, traf, tfhd, tfdt, and trun boxes. All arithmetic must be checked, and generated offsets must account for the final serialized header sizes.

The HTTP path generates the small moof/mdat header, groups adjacent samples into source ranges, and streams them in configurable chunks through a two-item bounded channel. Channel backpressure limits producer reads, and receiver cancellation stops subsequent reads. The developer package command still assembles complete files in memory because it writes local artifacts rather than serving concurrent clients.

Protocol adapters

HLS and DASH should consume the same MediaIndex and SegmentPlan.

The initial HLS adapter produces:

  • a master playlist;
  • a VOD media playlist with exact EXTINF values;
  • EXT-X-MAP pointing to the initialization segment;
  • fragmented MP4 media segment URLs;
  • EXT-X-ENDLIST for complete assets.

The initial DASH adapter produces a static MPD with an initialization URL and media URLs. SegmentTemplate with SegmentTimeline is the safest first representation because real segment durations vary with keyframe placement.

Manifest generation must escape untrusted values and must not expose filesystem paths.

HTTP contract

The implemented HLS routes are:

MethodRouteContent typeCache policy
GET, HEAD/healthtext/plainFramework default
GET, HEAD/readytext/plain200, or 503 once shutdown begins
GET, HEAD/metricsPrometheus textFramework default
GET, HEAD/hls/{asset}/master.m3u8application/vnd.apple.mpegurlpublic, max-age=60
GET, HEAD/hls/{asset}/{track}/index.m3u8application/vnd.apple.mpegurlpublic, max-age=60
GET, HEAD/hls/{asset}/{track}/init.mp4?v={version}video/mp4 or audio/mp4 by trackpublic, max-age=31536000, immutable
GET, HEAD/hls/{asset}/{track}/segments/{index}/media.m4s?v={version}video/mp4 or audio/mp4 by trackpublic, max-age=31536000, immutable

{track} is video or audio-{n}, with audio tracks numbered from 1 in file order. Asset identifiers contain only ASCII letters, digits, hyphens, and underscores. Relative URLs in each playlist resolve beneath that asset’s route and never expose a filesystem path. Init and media URLs require the v query parameter that the playlists emit; a missing or non-matching value is 404, so an immutable URL can never return different bytes. Cross-origin access is governed by the [cors] configuration. HEAD on a media segment answers from metadata and reads no source bytes.

Current and required status behavior:

ConditionStatusState
Unknown asset, track, or segment404 Not FoundImplemented
Malformed path parameter400 Bad RequestProvided by Axum
Unexpected generation/I/O failure500 Internal Server ErrorImplemented
Unsupported configured mediaStartup failureImplemented
Request body too largeNot applicable to current read-only routesImplemented by route shape
Single or suffix HTTP byte range on init/media206, or 416 when invalid/unsatisfiableImplemented
If-Range that does not match the current ETagRange ignored, full 200Implemented
Multi-range416 Range Not SatisfiableDeliberate first-release limitation
Conditional If-None-MatchStrong ETag; tag lists, weak tags, and * match; empty 304Implemented
Handler concurrency above max_concurrent_requests503 with Retry-AfterImplemented
Conditional If-Modified-SinceNot supportedDeliberate first-release limitation

Error responses must not include host filesystem paths or media payload data. Before production, internal error bodies must use a stable generic message while details remain in structured logs.

A segment request follows this path:

  1. Resolve an opaque asset identifier to an allowed local source.
  2. Look up metadata by stable source identity.
  3. Retrieve the startup-loaded immutable index and segment plan.
  4. Resolve the requested track and segment number through the segment plan.
  5. Generate the fragment header and stream only the selected source ranges.

Caching and asset lifecycle

The current implementation eagerly loads every configured asset before binding the listener. Each process stores one immutable MediaIndex, one SegmentPlan, and one initialization segment per track. There is no cache miss or request coalescing after startup. Media segments are regenerated for every request and should be cached by a reverse proxy or CDN.

The production design uses separate caches because their values and invalidation behavior differ:

  • Metadata cache: parsed MediaIndex and segment plan, keyed by canonical asset identity plus file size and modification identity.
  • Manifest/init cache: small generated responses, keyed by asset version and packaging settings.
  • Media segments: deterministic and cacheable by an external reverse proxy or CDN. An in-process segment cache should be added only after measurements justify its memory cost.

Runtime reload and cache invalidation are deferred to a separate asset-lifecycle design. The current process fails startup if any asset is invalid and treats loaded sources as immutable until restart.

HLS and DASH resource URLs include a version derived from the source moov SHA-256. Responses also carry strong resource-specific ETags. A process restart after atomic source replacement therefore produces new media URLs and validators.

Concurrency and performance

The intended hot path performs no codec work. Its main costs are metadata lookup, small header generation, source reads, and network writes.

  • Use asynchronous HTTP and bounded streaming bodies for many concurrent clients.
  • Keep blocking filesystem work away from asynchronous executor threads.
  • Share immutable indexes with reference-counted ownership.
  • Avoid a task per sample; process samples in contiguous byte-range groups.
  • Put explicit limits on concurrent metadata parses and open sources.
  • Place a caching proxy or CDN in front of the origin at scale.
  • Measure before adopting Linux-specific I/O such as io_uring or sendfile; generated headers plus multiple source ranges may make vectored streaming simpler.

Required resource limits

These are initial safety defaults, not benchmark results. They must become validated TOML settings before arbitrary media is accepted. Configuration may lower them; raising them requires capacity testing.

LimitDefaultFailure behaviorStatus
Configured assets1,000Configuration errorImplemented
Source file length1 TiBUnsupported mediaImplemented
Parsed MP4 metadata64 MiBInvalid mediaImplemented
Tracks per asset8Unsupported mediaImplemented
Samples per track2,000,000Invalid mediaImplemented
Samples per segment per track100,000Invalid mediaImplemented
Generated media segment payload64 MiBInternal error during startup/requestImplemented
Concurrent startup parses4Queue remaining work in dedicated poolImplemented
Concurrent segment-generation jobs2 per logical CPU, maximum 32Queue with timeout, then 503Implemented and configurable
Segment-generation queue wait2 seconds503 Service UnavailableImplemented and configurable
Request header bytes16 KiB431 Request Header Fields Too LargeImplemented after HTTP parsing
Whole request timeout30 seconds408 Request TimeoutImplemented
Logging queue8,192 recordsDrop new log recordsImplemented and configurable

Limits must be checked before allocation or table expansion. Checked arithmetic remains mandatory even below configured limits.

Initial performance budgets

These budgets are acceptance targets for a release build on a documented four-core x86-64 Linux reference host with local SSD storage and warm filesystem cache. Benchmarks must record CPU, storage, fixture, and command so results remain comparable.

MeasurementInitial target
Warm startup parse and plan, 60-minute assetp95 below 250 ms per asset
Cached master/media playlist responsep95 below 2 ms server time
Warm 6-second segment generation, excluding client transferp95 below 10 ms
Segment bytes read from sourceno more than payload bytes plus 256 KiB
Additional buffered memory per streaming requestno more than 512 KiB after direct streaming is implemented
Sustained concurrent streams1,000 with fewer than 0.1% origin 5xx responses
Event-loop blockingno filesystem operation longer than 1 ms on a Tokio worker

The HTTP producer uses a two-item queue and a default 256 KiB source chunk, keeping buffered payload near the 512 KiB target plus generated headers and framework overhead.

Initial performance metrics:

  • metadata parse duration and bytes read;
  • metadata cache hit rate and coalesced waiters;
  • manifest and fragment generation duration;
  • source bytes read versus response bytes written;
  • active streams, backpressure time, and aborted responses;
  • errors by parsing, planning, source I/O, and protocol category.

Structured logging

Service logs use tracing fields rather than interpolated prose. The configured output format is either newline-delimited JSON for production collectors or compact text for local development. The configured level is one of trace, debug, info, warn, or error.

Logging must not apply stdout backpressure to media requests. A dedicated tracing-appender worker writes log records from a bounded, lossy queue. When the queue is full, records are dropped instead of blocking request tasks. The worker guard remains alive for the service lifetime so queued records are flushed during orderly shutdown.

Dropped records are exposed as vod_log_dropped_lines_total on /metrics. A dedicated monitor checks the appender counter every ten seconds and writes a rate-limited warning directly to stderr when it increases, avoiding the saturated queue.

The level policy limits hot-path cost:

  • info: process lifecycle and one event per asset loaded at startup;
  • debug: HTTP request completion and one event per generated media segment;
  • warn: rejected client requests such as unknown assets or segments;
  • error: internal request failures and shutdown-listener failures;
  • trace: reserved for temporary diagnostics and never used per media sample.

No event logs media payloads, sample arrays, authentication values, or filesystem paths at info. Disabled debug events are filtered before formatting, and there is no event per MP4 sample.

VOD-only scope

This service packages complete, immutable MP4 assets for on-demand playback. Live ingest, simulated live playlists, sliding windows, partial-segment publication, blocking playlist reload, and LL-HLS are excluded from the project scope. Supporting them would require a different stateful ingest and publication architecture and is not part of the current roadmap.

Reference architecture

How nginx-vod-module works records the reference investigation and its optional FFmpeg usage. This design adopts its separation between packaging and decoding, while using Rust, Axum, and Tokio and requiring explicit resource limits before production.

Configuration boundary

Iteration one uses TOML only. It is native to the Rust ecosystem, has an unambiguous data model for this configuration, and avoids maintaining duplicate TOML and YAML parsing, diagnostics, examples, and tests. YAML can be proposed later if an operational requirement justifies it.

The configuration loader deserializes TOML into typed settings and validates them before the server binds its listener. It currently includes the listen address, one canonical media root, an explicit asset map, target segment duration, and logging level/format/queue capacity. Configuration is read only at startup; hot reload is not supported.

Parser, segment-generation, concurrency, queue, stream-chunk, header, timeout, logging, storage, and asset limits are implemented configuration fields. Protocol toggles and an absolute public base URL are not needed for the current relative-URL origin. Runtime source refresh is deferred to a separate lifecycle design. Unknown fields are rejected so misspelled or premature settings cannot be silently ignored.

Security and resource limits

  • Treat MP4 files and all box lengths, counts, offsets, and timestamps as untrusted.
  • Use checked arithmetic for offsets, durations, and allocation sizes.
  • Reject paths that escape configured media roots, including through symlinks.
  • Resolve public asset IDs separately from filesystem paths.
  • Enforce the concrete box, metadata, track, sample, segment, header, and concurrency limits in this document before accepting arbitrary sources.
  • Return stable client errors for unsupported media and internal errors for unexpected failures without leaking host paths.
  • The media_pipeline fuzz target drives bounded raw MP4 preflight, table expansion, segment planning, init writing, and fragment preparation. Stable CI compiles the target; nightly fuzz campaigns use the synthetic fixtures as seeds.

Correctness and performance validation

The validation contract and current evidence are:

CheckStatusEvidence or required work
Source index matches trusted tracks, counts, keyframes, offsets, DTS/PTS, and durationsImplementedRust test compares every packet with committed FFprobe JSON
Generated init and media fragments parse and decodeImplementedPackage and protocol tests validate generated media with FFmpeg
Complete HLS presentation decodesImplementedCI-installed FFmpeg consumes the ephemeral HTTP master URL
HLS protocol validator accepts outputPendingAdd an automated validator suitable for Linux CI
Complete DASH presentation decodesImplementedCI-installed FFmpeg consumes the ephemeral HTTP MPD
DASH protocol validator accepts outputPendingAdd maintained DASH-IF conformance tooling
Browser starts, seeks, and plays HLSPendingAdd Playwright with pinned hls.js
Browser starts, seeks, and plays DASHPendingAdd Playwright with pinned dash.js after DASH exists
Segment response reads only requested source payloadImplementedGenerated header plus only overlapping source ranges are streamed with backpressure
Repeated generation is byte-identicalImplementedIntegration test compares every generated artifact byte-for-byte across two runs
Release benchmarks meet the stated budgetsMeasured on a laptop; reference host pendingmake bench; every budget passes on an i7-8550U, see benchmarks. Reference-host record still required

Fixture coverage is similarly explicit:

Fixture characteristicStatus
H.264/AAC-LC, moov before mdat, stco, constant video timing, B-framesImplemented
moov after mdatImplemented
co64 chunk offsetsPending
Variable frame timingImplemented with two packet-duration classes
Additional AAC sample rates and channel layoutsImplemented for 44.1 kHz stereo alongside 48 kHz mono
No-audio videoImplemented
Malformed box sizes/countsImplemented for undersized child boxes and continuously exercised by fuzzing
Truncated metadata and sample payloadImplemented
Edit listsImplemented rejection fixture
Encrypted sample entriesImplemented raw-structure mutation test

Benchmarks must separately measure metadata parsing, segment planning, fragment-header generation, local file throughput, allocation count, and concurrent request behavior. Optimization decisions require release profiles from representative MP4 files.

Implementation stages

  1. Complete: generate a synthetic H.264/AAC fixture and commit its FFprobe packet oracle.
  2. Complete for the supported fixture: adopt mp4 0.14 and derive exact sample metadata without reading mdat payloads.
  3. Complete: define and test the protocol-neutral media index.
  4. Complete: implement checked, keyframe-aligned video and audio segment plans.
  5. Complete for the supported fixture: generate separate-track init/media fragments and validate them with FFmpeg.
  6. Complete: serve TOML-mapped HLS through Axum and Tokio with bounded direct range streaming.
  7. Complete: enforce parser, memory, concurrency, timeout, header, and error-disclosure limits; add source mutation checks and rejection fixtures.
  8. Complete for functional behavior: replace HTTP whole-segment buffering with bounded backpressured source-range streaming. Load benchmarks remain pending.
  9. Partially complete: automate HLS and DASH decode over HTTP. Formal conformance and browser tests remain pending.
  10. Complete: add the static DASH adapter and FFmpeg validation over shared fragments.
  11. Complete for immutable restart lifecycle: use versioned resource URLs and ETags. Runtime catalog reload remains deferred.
  12. Later designs: adaptive bitrate sets, remote sources, and encryption/DRM. Live streaming remains excluded from project scope.

First-iteration decisions

These choices answer the design’s initial open questions. They define the first implementation, not promises that every dependency or limitation is permanent.

MP4 parser and writer

Use the pure-Rust mp4 crate version 0.14 for the parser spike. It exposes ISO BMFF boxes, track metadata, sample count, decode start time, duration, composition offset, sync status, codec configuration, and box-writing traits. This is a better first fit than pulling in a complete transcoding framework.

Do not use Mp4Reader::read_sample on the production segment hot path because it returns sample payload bytes. Build the immutable MediaIndex from public sample-table boxes and use our MediaSource for positioned payload reads. Implement the small set of fragment boxes required by the design locally when the crate’s generic writer cannot stream the desired layout without buffering.

The spike accepts mp4 only if all fixture offsets, DTS, PTS, durations, sync flags, and codec configuration match FFprobe; parsing stays within configured metadata limits; and the index can be built without reading all mdat payloads. If it fails any criterion, use Mozilla’s pure-Rust mp4parse for parsing and keep our own fragment writer. This fallback is explicit so the spike cannot stall the project.

Segment layout

Use one track per initialization segment and media segment from the first release. Video and audio have separate URLs, moof/mdat pairs, and timelines. HLS references audio through EXT-X-MEDIA; DASH uses separate video and audio Adaptation Sets.

Separate tracks match common CMAF packaging, simplify trun data offsets and timing, avoid audio/video interleaving, and prepare for alternate audio and adaptive video. Muxed audio/video fragments and MPEG-TS are deferred compatibility features.

Initial media profile

Support clear, static VOD with one H.264/AVC avc1 video track and optional AAC-LC mp4a.40.2 audio. Require codec configuration in the sample entry, one sample description per selected track, non-encrypted samples, and video segment starts on sync samples. Preserve B-frame composition offsets. Reject edit lists that alter presentation timing in iteration one.

Generate fragmented MP4 intended to be CMAF-compatible, HLS VOD playlists using EXT-X-MAP, and static DASH MPDs using SegmentTemplate plus SegmentTimeline. Do not claim formal CMAF conformance until the conformance suite passes. HEVC, AV1, Dolby codecs, encryption, subtitles, and multiple renditions are outside iteration one.

HTTP and I/O stack

Use Axum 0.8 on Tokio 1, with Hyper and Tower through Axum. Axum provides typed routing and responses, Tokio provides scheduling and bounded blocking work, and Tower provides timeouts, tracing, limits, and other middleware without a custom server framework.

The first supported platform is Linux. Local payload reads use positioned file I/O so requests never share a mutable seek cursor. A semaphore and queue timeout gate segment jobs. The HTTP body receives a generated header and bounded source chunks through a two-item channel; backpressure blocks only the dedicated producer, and receiver cancellation stops subsequent reads.

Do not adopt io_uring, memory mapping, or sendfile in iteration one. Generated headers plus disjoint source ranges reduce the benefit of a single-file send path. Profile the bounded positioned-read implementation before selecting a Linux-specific optimization.

Asset mapping

Use an opaque, URL-safe asset ID in routes and an explicit TOML catalog that maps each ID to a path relative to one configured media root. A representative configuration is:

[storage]
media_root = "/srv/vod"

[assets.big-buck-bunny]
path = "movies/big-buck-bunny.mp4"

The route contains big-buck-bunny, never a filesystem path. On startup, validate asset IDs, reject absolute asset paths, canonicalize the media root and each existing source, and require every resolved source to remain beneath the root. Keep this behind an AssetResolver interface so a database or remote mapping service can replace the TOML catalog later.

Source identity and mutation

The current process treats source media as immutable after startup. Publishers must not replace or modify configured media while the process is running. Runtime replacement is unsupported until cache invalidation and URL versioning are implemented.

The implemented identity contains canonical path, device, inode, byte length, nanosecond modification time, and the raw moov SHA-256. The parser compares filesystem metadata and the moov hash before and after parsing and discards a result if the source changed. A restart after atomic replacement produces a new identity and versioned public URL. In-place changes that deliberately preserve every identity field remain unsupported operator error.

Validation tools and fixtures

Use these validation layers. FFprobe comparison and automated FFmpeg playback are implemented; the remaining items are verification work:

  • Rust unit and property tests for box arithmetic, timing rescaling, sample-table expansion, and segment boundaries.
  • Synthetic fixtures generated from FFmpeg test video and sine-wave sources, with the generation command committed alongside expected FFprobe JSON. This avoids third-party media licensing.
  • ffprobe comparisons for source indexes and generated fragments, followed by ffmpeg -v error decode checks over complete generated HLS and DASH presentations.
  • Playwright smoke tests with hls.js and dash.js in Chromium for startup, duration, seeking, and playback without fatal player errors.
  • Apple’s Media Streaming Validator on a macOS release job when macOS CI is introduced.
  • The maintained DASH-IF Conformance tooling in a scheduled or release job rather than every fast pull-request job.

FFmpeg is an external test and fixture tool, not linked into or invoked by the production service. hls.js uses Apache-2.0 and dash.js uses BSD licensing; tests should pin their versions. Generated fixtures remain project-owned test artifacts, and every third-party fixture added later must record its source and redistribution license.

References

TDD 0002: Asset mapper interface

  • Status: Accepted; implemented
  • Created: 2026-09-19
  • Updated: 2026-09-20
  • Related ADRs: None yet. Required before implementation: async media source, outbound HTTP client (see Decisions)
  • Related designs: TDD 0001, TDD 0003 (the hardened HTTP layer this design builds on)

Summary

Let segmentor resolve an asset ID to a media location by asking an external mapper service, instead of reading a fixed TOML catalog at startup. When a client requests an asset the server has not loaded, it queries the mapper with the asset ID, receives a location descriptor, opens that location as a media source, parses and plans it, and serves it through the existing HLS and DASH routes.

This document defines:

  1. the Rust-side AssetResolver interface that replaces the hard-wired TOML catalog;
  2. the HTTP/JSON wire specification a mapper service must implement;
  3. the lazy-loading, caching, and failure behavior that follows from resolving assets at request time;
  4. the configuration, security, observability, and rollout plan.

The existing TOML catalog stays as the static resolver, so current deployments keep working unchanged.

Implementation status

Everything in the rollout is implemented and tested: the async media source, the registry, the static and mapper resolvers, file and http locations, and the SSRF controls. The mapper contract is also published as a standalone reference for mapper authors in Mapper API reference; how the code works is in Registry and resolvers.

Places where the implementation differs from the first draft of this document:

DraftImplemented
Registry knobs (max_cached_resolutions, max_loaded_bytes, max_concurrent_loads, load_queue_timeout_ms) inside [resolver.http]They apply to both resolvers, so they live in a [registry] table. The byte budget is the existing limits.max_index_bytes and the concurrent-load bound is the existing limits.max_startup_parses, so there is one memory knob
allowed_http_hosts, allow_insecure_http, and remote timeouts in [resolver.http]A separate [remote_media] table, because they govern media reads rather than mapper calls
Address filter: private ranges refused “unless the host is explicitly allow-listed”Hosts must always be allow-listed, so that clause could never apply. The rule is now an explicit remote_media.allow_private_addresses switch, defaulting to off
MediaSourceKind with a stream_range methodPayload reads use read_range in chunks (stream_chunk_bytes), which the streaming task already does, so a separate streaming method was unnecessary
Parse over a “virtual reader” to be confirmed by a spikeConfirmed: mp4::Mp4Reader::read_header runs over SparseFile, which holds only box headers plus ftyp and moov. The spike passed, so the raw-box-walk fallback was not needed. The same path is now used for local files too, which removed a second read of moov
A changed location was not part of the cache keyA new object for the same version drops the loaded copy. A URL that differs only in its query (a re-signed URL) instead rotates in place on the loaded asset, refreshes ahead of expires_at, and is re-fetched once when the origin rejects a read
Startup preload was an open questionregistry.preload (default on) loads the static catalog before serving so a bad file stops startup. It is a no-op for a mapper
Reachability probe was optionalImplemented as resolver.http.readiness_probe_interval_ms; zero disables it
A weak ETagRefused as a validator, since If-Range requires a strong one. Last-Modified is the fallback

Context

Today the asset lifecycle is fixed at startup (TDD 0001, “Caching and asset lifecycle”; the HTTP layer around it is described in TDD 0003):

  • src/config/ parses [assets.<id>] path = "..." into BTreeMap<String, PathBuf> and validates every path beneath storage.media_root.
  • Before this design, AppState::load parsed every asset with a rayon pool before the listener bound, and a bad asset failed startup. (Now: AppState::new plus preload, see Implementation status.)
  • PackagedAsset in src/asset.rs holds a concrete LocalMediaSource, the MediaIndex, the SegmentPlan, and init segments.
  • AppState.assets is an immutable HashMap. An unknown ID is a 404. There is no cache miss path, no request coalescing, and no reload.
  • SourceIdentity in src/source/mod.rs is filesystem-shaped (canonical path, device, inode, mtime).

TDD 0001 anticipated this change in two places: the “Asset mapping” decision says the TOML catalog should sit “behind an AssetResolver interface so a database or remote mapping service can replace the TOML catalog later”, and the non-goals defer remote sources. Runtime cache invalidation is marked Deferred. This design takes on the resolver and the lazy lifecycle it requires. Remote byte sources are covered only to the extent the resolver must be able to name them (see Media locations).

Goals

  • Define one resolver interface the HTTP layer depends on, with the TOML catalog and the mapper client as interchangeable implementations.
  • Specify a small, versioned, implementation-neutral HTTP/JSON contract so any service (a database-backed API, a CMS, an object-store index) can act as a mapper.
  • Resolve assets lazily on first request, with bounded concurrency and request coalescing, and without blocking the Tokio executor.
  • Bound memory, mapper load, and startup cost: loaded assets and cached resolutions are limits-controlled.
  • Keep the mapper a location service: it never sees or returns media bytes, and segmentor never trusts it to return safe locations without validation.
  • Degrade predictably when the mapper is slow or down: serve already-loaded assets, return stable status codes for the rest.
  • Preserve every existing public route, URL version scheme, ETag behavior, and limit.

Non-goals

  • Authoring, uploading, or managing assets. The mapper is read-only from this service’s point of view.
  • Returning manifests, segment plans, or media metadata from the mapper. segmentor still derives everything from the media itself.
  • Per-viewer authorization, signed playback URLs, DRM, or entitlement checks. The mapper answers “where is asset X”, not “may viewer Y watch it”. Viewer authorization belongs in a front proxy.
  • Push-based invalidation (webhooks, message queues). Freshness is TTL and version based in this iteration.
  • Multi-rendition or adaptive sets (one asset ID still maps to one MP4).
  • Non-HTTP mapper transports such as gRPC. The AssetResolver interface leaves room for them.
  • Backward compatibility with the pre-release internal APIs and public URLs. The project has not been released, so this design breaks them where that gives a simpler or faster result (see Decisions).

Design

Component overview

flowchart LR
    HTTP[HTTP router] --> Registry[Asset registry]
    Registry --> LoadedCache[Loaded asset cache]
    Registry --> ResCache[Resolution cache]
    Registry --> Resolver{AssetResolver}
    Resolver --> Static[Static TOML resolver]
    Resolver --> Mapper[HTTP mapper client]
    Mapper -->|GET /v1/assets/id| Service[(Mapper service)]
    Registry --> Loader[Source open + parse + plan]
    Loader --> Source[Media source]

The asset registry replaces AppState.assets. It owns two caches and is the only component that talks to the resolver:

  • Resolution cache: asset_id -> ResolvedAsset, with expiry. Answers “where is it” without calling the mapper.
  • Loaded asset cache: LocationKey -> Arc<PackagedAsset>. Answers “is it parsed” without reopening the source.

Resolver interface

The resolver has one operation: turn an asset ID into a location. It performs no parsing and never opens media.

#![allow(unused)]
fn main() {
/// What the mapper (or TOML catalog) says about one asset.
pub(crate) struct ResolvedAsset {
    /// Echo of the requested ID; the registry rejects a mismatch.
    pub(crate) asset_id: String,
    /// Where the media lives.
    pub(crate) location: AssetLocation,
    /// Opaque change token, required. Equal versions mean the same media.
    pub(crate) version: String,
    /// Absolute time after which this answer must not be used without revalidating.
    pub(crate) valid_until: Instant,
}

pub(crate) enum AssetLocation {
    /// Path relative to `storage.media_root`.
    File { path: PathBuf },
    /// Absolute `https` (or allowed `http`) URL serving the MP4 with byte-range support.
    Http { url: Url },
}

pub(crate) enum ResolveError {
    /// The mapper definitively says the asset does not exist.
    NotFound,
    /// The mapper could not be reached, timed out, or returned 429/5xx.
    Unavailable(String),
    /// The mapper answered but the answer is invalid or forbidden by policy.
    Rejected(String),
}
}

Dispatch is an enum, not a trait object, because the operation is async and the crate targets Rust 1.88 without async-trait:

#![allow(unused)]
fn main() {
pub(crate) enum AssetResolver {
    Static(StaticResolver),   // TOML catalog, no I/O, always fresh
    Http(HttpResolver),       // mapper client
}

impl AssetResolver {
    pub(crate) async fn resolve(
        &self,
        asset_id: &str,
        known_version: Option<&str>,
    ) -> Result<Resolution, ResolveError>;   // async fn; no blocking I/O
}

pub(crate) enum Resolution {
    /// A new or changed answer.
    Resolved(ResolvedAsset),
    /// The mapper confirmed `known_version` is still current (HTTP 304).
    Unchanged { valid_until: Instant },
}
}

known_version lets the registry revalidate cheaply with If-None-Match instead of downloading an identical answer.

Media locations

AssetLocation is the contract between mapper and server. Two kinds are defined:

KindMeaningServer handling
fileA path relative to storage.media_rootSame canonicalize-and-contain check as the TOML catalog, then LocalMediaSource::open
httpA URL that serves the MP4 bytes and supports Range requestsHttpMediaSource (async range reads over a pooled HTTP client)

Unknown location types are rejected, not skipped, so a mapper cannot silently downgrade an asset.

Async media source

Performance is the priority, so the remote source is asynchronous rather than blocking reads wrapped in threads. TDD 0003 already moved the segment response path to an async task that holds a job slot only during each read, but it still reads through the synchronous MediaSource::read_range on spawn_blocking. That is acceptable for local files, where the blocking hop is one positioned read. For a remote origin each read is a network round trip, so a parked blocking thread per in-flight read would not scale. The source itself therefore becomes asynchronous.

The source becomes an enum with async methods (native async fn is not dyn-compatible, and an enum avoids an async-trait dependency):

#![allow(unused)]
fn main() {
pub(crate) enum MediaSourceKind {
    Local(LocalMediaSource),   // positioned reads via spawn_blocking, one hop per chunk
    Http(HttpMediaSource),     // Range GET over a shared, pooled, HTTP/2-capable client
}

impl MediaSourceKind {
    pub(crate) fn identity(&self) -> &SourceIdentity;
    pub(crate) fn len(&self) -> u64;
    /// Small reads (box headers, `moov`): returns the bytes.
    pub(crate) async fn read_range(&self, range: ByteRange) -> Result<Bytes>;
    /// Payload reads: yields bounded chunks without buffering the whole range.
    pub(crate) fn stream_range(&self, range: ByteRange, chunk: usize)
        -> impl Stream<Item = Result<Bytes>> + Send;
}
}

Consequences for the pipeline:

  • Streaming. StreamJob in src/http/ keeps its structure (header chunk, then coalesced ranges, each send bounded by the idle timeout) but calls MediaSourceKind::read_range directly instead of wrapping PackagedAsset::read_range in spawn_blocking. The local variant performs that spawn_blocking internally.
  • Concurrency gating. The job slot still surrounds each source read. A separate max_inflight_source_reads limit bounds outbound requests to a remote origin.
  • Local reads. Unchanged: read_exact_at on the blocking pool per chunk (about 256 KiB). io_uring remains a later, measured optimization.
  • Remote reads. One shared client per process, keep-alive, HTTP/2 where offered, Range: bytes=a-b, response checked for 206, matching Content-Range, and a stable validator (ETag or Last-Modified) sent as If-Range so a changed object fails the request instead of mixing bytes from two versions.
  • Parsing. mp4::parse stays synchronous CPU work, run on the bounded blocking pool over in-memory bytes. The async side reads only the top-level box headers and the moov payload (already isolated in find_moov, bounded by max_metadata_bytes) and hands the bytes to the parser. The mp4 crate needs a Read + Seek input, so the parser is given a virtual reader over the file layout in which only ftyp/moov are backed by fetched bytes and mdat is skipped rather than fetched. Whether Mp4Reader::read_header can be driven that way without touching mdat must be confirmed by a spike; if not, the fallback is the raw box walk already in src/mp4/parser.rs, which validates moov before the crate is used.
  • Mutation checks. verify_unchanged (inode, mtime) is a local-only concept. For http sources the equivalent is: the validator captured on the first range read must match on every later read, and the moov hash is still compared before and after the parse.

Asset registry behavior

registry.get(asset_id) -> Result<Arc<PackagedAsset>, RegistryError>:

  1. Validate the ID with the existing validate_asset_id rules before any lookup. An invalid ID is 404 and never reaches the mapper, so path-like or oversized input cannot be forwarded.
  2. Check the resolution cache. If a fresh entry exists, go to step 5.
  3. Resolve, coalesced. At most one in-flight resolve per asset ID; concurrent requests await the same result. If a stale entry exists, send its version as If-None-Match. The shared resolve runs as its own task, so one waiting request being cancelled never cancels it for the others.
  4. Store the answer. Unchanged extends the existing entry’s valid_until. Resolved replaces it. NotFound is cached as a negative entry for negative_ttl_ms.
  5. Find or load the packaged asset. The loaded-asset key is (asset_id, version); the location is not part of the key, because equal versions mean identical media. On a miss, load coalesced per key: open the source, read moov asynchronously, run mp4::parse, segment::plan, and build init segments on the bounded blocking pool, then insert into the loaded cache.
  6. Return Arc<PackagedAsset>. In-flight requests keep their Arc even if the cache later evicts or replaces the entry, so a segment already streaming is never torn down.

A changed version yields a new loaded-cache key and there is no grace period for the previous one (see Decisions). The old PackagedAsset stays valid for requests already holding it and is dropped when they finish.

CPU-bound work (parse, plan) runs on a dedicated bounded pool gated by max_concurrent_loads (the existing max_startup_parses, renamed). No parse runs on a Tokio worker. I/O during a load is async.

Memory budget

A parsed asset is dominated by Vec<Sample>: about 40 bytes per sample, so a track at the max_samples_per_track limit (2,000,000) is roughly 80 MB. An entry-count bound such as max_loaded_assets alone cannot bound memory. The loaded cache is therefore weighted: each entry’s weight is its estimated index bytes (samples times the sample size plus init segments), and the cache evicts least-recently-used entries above max_loaded_bytes. An asset larger than the whole budget is loaded, served, and not retained. PackagedAsset::index_bytes from TDD 0003 already computes this weight, including the playlists rendered at load, and the static catalog uses it for its startup budget (limits.max_index_bytes).

Immutable URLs across version changes

Init and media URLs are served immutable and already require the current v (TDD 0003). With runtime location changes that check is the safeguard against a CDN caching new media under an old URL: after a version change, old v values stop resolving.

Mapper wire specification

The contract is versioned by URL prefix (/v1). Mapper implementations must ignore unknown request headers; segmentor ignores unknown JSON response fields, so additive changes are non-breaking.

Resolve an asset

GET {base_url}/v1/assets/{asset_id}

Request headers:

HeaderRequiredMeaning
Accept: application/jsonYes
Authorization: Bearer <token>If configuredStatic token from configuration or an environment variable
If-None-Match: "<version>"OptionalSent on revalidation; the mapper may answer 304
X-Request-IdYesCorrelation ID generated by segmentor, also logged
User-Agent: segmentor/<version>Yes

{asset_id} is already validated to ASCII letters, digits, -, and _ (1 to 128 bytes), so it needs no escaping.

200 OK with Content-Type: application/json:

{
  "asset_id": "big-buck-bunny",
  "version": "2026-09-18T10:22:31Z#7",
  "ttl_seconds": 300,
  "location": {
    "type": "file",
    "path": "movies/big-buck-bunny.mp4"
  }
}
{
  "asset_id": "big-buck-bunny",
  "version": "etag-9f2c",
  "ttl_seconds": 300,
  "expires_at": "2026-09-19T12:00:00Z",
  "location": {
    "type": "http",
    "url": "https://origin.example.net/movies/big-buck-bunny.mp4"
  }
}
FieldTypeRequiredRules
asset_idstringYesMust equal the requested ID exactly
versionstringYesOpaque, 1 to 256 printable ASCII bytes. Equal versions mean identical media; any change of media content must change it. Also returned as the ETag header (required on 200 so If-None-Match revalidation works)
ttl_secondsintegerNoHow long the answer may be reused. Clamped to [min_ttl_ms, max_ttl_ms]; defaults to default_ttl_ms when absent
expires_atRFC 3339 timestampNoHard deadline for the location, for example a pre-signed URL. The effective validity is the earlier of TTL and expires_at. Never extended by revalidation
location.type"file" or "http"YesUnknown values are rejected
location.pathstringFor fileRelative, no .., no leading /, no NUL, at most 4096 bytes
location.urlstringFor httpAbsolute; scheme, host, and redirects are policy-checked (see Security and limits)

The response body is limited to max_response_bytes. The mapper must not include credentials in location.url userinfo; such URLs are rejected. Pre-signed query parameters are permitted and redacted from logs.

304 Not Modified: the version sent in If-None-Match is still current. The body is empty. Cache-Control: max-age=N on a 200 or 304 is honored as ttl_seconds when the body field is absent.

Errors

Error responses use Content-Type: application/json where possible:

{ "error": { "code": "asset_not_found", "message": "no asset with that ID" } }
Mapper statusMeaningsegmentor behavior
404Asset does not existResolveError::NotFound; client gets 404; negative-cached
410Asset existed and was removedSame as 404; also evicts any loaded copy
401, 403This service is not authorizedResolveError::Rejected; client gets 502; logged at error (operator misconfiguration)
429Mapper is shedding loadUnavailable; honors Retry-After for backoff; client gets 503
5xx, timeout, connect errorMapper unhealthyUnavailable; retried within the deadline; client gets 503
2xx invalid body, ID mismatch, policy violationMalformed answerRejected; client gets 502; never cached as valid
Any other 4xxContract violationRejected; client gets 502

The error.code is informational for logs. Behavior is decided by HTTP status only, so mappers do not need to agree on codes.

Health (optional)

GET {base_url}/v1/health   ->   200

Used only by the readiness probe. Mappers without it are supported by disabling the probe.

Out of scope for the wire contract

No listing, search, batch resolve, or write endpoints. A batch resolve is the obvious first extension if per-request latency proves a problem; it would be a new /v1 path and would not change the single-asset endpoint.

Failure behavior

Client-visible outcomes for a request whose asset is not already loaded and fresh:

SituationClient response
Invalid asset ID syntax404; mapper not called
Mapper: not found or gone404
Mapper: unavailable, no usable cached answer503 with Retry-After
Mapper: unauthorized, malformed, or policy-rejected answer502 with a generic body
Location kind not supported by this build502 with a generic body
Source cannot be opened or parsed (unsupported media, missing file)Same classification as TDD 0001 media errors; internal detail only in logs
Load queue wait exceeds load_queue_timeout_ms503
Overall request deadline exceeded408 (existing timeout layer)

Stale-if-error: if revalidation fails with Unavailable and a previously good answer exists, the registry may keep serving it for up to stale_if_error_ms past valid_until and logs each such use. A 404 or 410 always wins over stale data. An expires_at location deadline is never served stale.

Error bodies never contain mapper URLs, resolved locations, filesystem paths, or tokens, consistent with the existing error-disclosure rule.

Configuration

The current layout keeps working: a config with [assets.*] and no [resolver] is a static resolver. The two forms are mutually exclusive, and deny_unknown_fields still applies.

[storage]
media_root = "/srv/vod"        # still required when locations may be `file`

[resolver]
type = "http"

[resolver.http]
base_url = "https://mapper.internal.example.net"
bearer_token_env = "VOD_MAPPER_TOKEN"   # env var name; the token itself is never in the file
connect_timeout_ms = 500
request_timeout_ms = 2000
max_retries = 2                          # idempotent GET only, jittered backoff, within request_timeout_ms
max_response_bytes = 16384
default_ttl_ms = 300000
min_ttl_ms = 5000
max_ttl_ms = 3600000
negative_ttl_ms = 5000
stale_if_error_ms = 60000
max_cached_resolutions = 10000
max_loaded_bytes = 2147483648                 # weighted budget, see Memory budget
max_concurrent_loads = 4
max_inflight_source_reads = 64                # outbound range requests to remote origins
load_queue_timeout_ms = 5000
allowed_http_hosts = ["origin.example.net"]   # empty means `http` locations are rejected
allow_insecure_http = false                   # permit http:// (not https://) locations

Validation at startup: base_url must be https unless allow_insecure_mapper = true (development); every numeric limit must be greater than zero; min_ttl_ms <= default_ttl_ms <= max_ttl_ms; the token variable must be set if named. The bearer token is never logged.

The LimitsConfig.max_assets limit keeps its meaning for the static resolver. max_loaded_bytes bounds the lazy cache: on overflow the least recently used entries are dropped from the cache (in-flight requests keep their Arc).

Interface changes to existing code

AreaChange
Configassets: BTreeMap<..> becomes part of ResolverConfig::Static; add ResolverConfig::Http
AppStateReplace assets: Arc<HashMap<..>> with Arc<AssetRegistry>; asset() becomes async
MediaSource traitRemoved; replaced by the MediaSourceKind enum with async reads
PackagedAsset::loadasync; takes an opened MediaSourceKind instead of a path
fmp4::write_media_segmentAsync or test-only; the serving path uses the streaming pipeline
SourceIdentityNot filesystem-only: an enum with Local {..} and Remote { url_without_query, length, etag, last_modified } variants
ErrorAdd resolver variants; map to the status table above
HTTP handlersstate.asset(&id)? becomes state.asset(&id).await?; no route or response change

Security and limits

  • The mapper is a trust boundary. Everything it returns is validated before use. Validation failures are Rejected, never partially honored.
  • Path containment. file locations receive the same treatment as TOML paths: reject absolute paths, .., and NUL, canonicalize beneath storage.media_root, require a regular file, and re-check after canonicalization so symlinks cannot escape.
  • SSRF for http locations. A mapper-supplied URL makes the server issue requests, so it is allow-listed:
    • scheme https by default; http only with allow_insecure_http = true;
    • host must match allowed_http_hosts (exact match, no wildcards in the first version);
    • resolved IP addresses must not be loopback, link-local, private, or multicast unless the host is explicitly allow-listed; the check applies to the connected address, not just the name, to defeat DNS rebinding;
    • redirects are not followed by default;
    • userinfo in the URL is rejected.
  • Mapper transport. https required by default; the bearer token is read from an environment variable, redacted from logs, and sent only to base_url’s origin. A mapper compromise can redirect assets to other allow-listed locations, but cannot make the server read paths outside media_root or contact hosts outside the allow-list.
  • Resource limits, all checked before allocation: response size, resolution cache entries, loaded assets, concurrent loads, load queue wait, per-request mapper deadline, retry count, and TTL clamps. Existing parser limits (max_source_bytes, max_metadata_bytes, and so on) apply unchanged to every location kind.
  • Amplification. Coalescing ensures N concurrent misses on one ID cost one mapper call and one load. Negative caching stops repeated probes for a missing ID from reaching the mapper on every request. Requests for IDs that fail syntax validation never reach it.
  • Cache poisoning. A resolution is cached only after its asset_id matches the request and the whole answer validates. A media-parse failure discards the loaded entry and caches a short negative result, so a bad file cannot make every request re-parse it.
  • Information disclosure. Client errors are generic. Locations, mapper URLs, and query strings (which may hold pre-signed credentials) are excluded from client responses and from info-level logs.

Observability

Structured tracing events, following the existing level policy:

LevelEvent
infoasset_loaded (existing, now also emitted for lazy loads, with `resolver = static
debugasset_resolve_started, asset_resolved (elapsed_ms, `cache = hit
warnasset_not_found, resolve_stale_served, resolve_retry
errorresolve_rejected, resolve_unauthorized, asset_load_failed

No event includes bearer tokens, full location URLs with query strings, or filesystem paths at info. Every mapper request carries an X-Request-Id that appears in both logs.

Metrics added to /metrics:

  • vod_resolver_requests_total{outcome="ok|not_found|unavailable|rejected|unchanged"}
  • vod_resolver_request_duration_seconds (histogram)
  • vod_resolution_cache_events_total{event="hit|miss|revalidate|stale|negative_hit"}
  • vod_loaded_assets (gauge) and vod_asset_loads_total{outcome}
  • vod_asset_load_duration_seconds (histogram) and vod_asset_load_queue_wait_seconds
  • vod_resolver_coalesced_waiters_total

Readiness: /health remains process liveness. A separate readiness signal reports mapper reachability from the most recent background probe when the HTTP resolver is enabled, so an orchestrator can choose to withhold traffic during a mapper outage without restarting the process.

Testing

  • Unit: location validation (path traversal, absolute paths, NUL bytes, oversized fields, unknown type); TTL clamping and expires_at precedence; response parsing with unknown fields, missing fields, and mismatched asset_id; SSRF checks for each rejected address class and scheme; status-to-ResolveError mapping for every row of the error table.
  • Registry (with a fake resolver): single flight under N concurrent requests; coalesced result fan-out on failure; negative caching; stale-if-error window and its exclusion for 404/410 and expires_at; location change producing a new loaded-cache key while an old Arc keeps streaming; weighted LRU eviction at max_loaded_bytes; cancellation of a waiting request not cancelling the shared load.
  • Mapper client integration: an in-process mock mapper on an ephemeral port covering 200, 304, 404, 410, 401, 429 with Retry-After, 500, slow response past the deadline, oversized body, invalid JSON, redirect responses, and connection reset.
  • Contract tests: the JSON examples in this document are committed as fixtures and parsed by the client, so the spec and implementation cannot drift silently.
  • End to end: existing HLS/DASH FFmpeg decode tests run once against the static resolver and once against a mock mapper returning file locations for the same fixtures, asserting byte-identical manifests and segments.
  • Regression: the full existing suite passes unchanged with a TOML-only config.
  • Fuzzing: add the mapper response parser and location validator as a fuzz target.
  • Performance: measure added latency on a cold first request (mapper round trip plus parse), and confirm hit-path segment latency is unchanged against the TDD 0001 budgets, including no mapper call on a warm resolution cache.

Rollout

All stages are done. The first items were delivered by TDD 0003: the async streaming path with per-read job slots and idle timeouts, precomputed playlists, v enforcement, and the index memory budget. The stages below build on it.

  1. Async media source: replace MediaSource with MediaSourceKind (local variant only), make PackagedAsset::load and the parser entry point async-aware, and keep every existing test green. This is a refactor with no behavior change.
  2. Registry and static resolver: introduce AssetRegistry, AssetResolver::Static, the async state.asset() path, the weighted loaded cache, and single-flight loading, with an optional lazy = true for the static resolver to exercise lazy loading.
  3. Mapper client, file locations: implement HttpResolver, the wire contract, TTL/negative/stale handling, configuration, and the mock-mapper test suite. Publish the specification as a standalone reference document for mapper authors, generated from the fixtures.
  4. http locations: HttpMediaSource, the parse spike over moov-only bytes, SourceIdentity generalization, and the SSRF controls. Until it ships, http locations are rejected with the 502 outcome.
  5. Later, only if measured need: batch resolve, push invalidation, mapper high availability with multiple base URLs, mTLS.

Rollback before release is a revert. After release, removing [resolver] and restoring [assets.*] returns to the static resolver. The mapper contract is additive under /v1; a breaking change requires /v2 and support for both during migration.

Decisions

Recorded from review of the first draft.

QuestionDecisionConsequence
Blocking or async media sourceAsyncMediaSource becomes MediaSourceKind (stage 1). ADR required
HTTP client dependencyAcceptedCandidate reqwest with rustls, default features off, or hyper-util directly. ADR must cover TLS backend, DNS/redirect control (SSRF), pool limits, and binary size
Is version mandatoryYesSimpler cache key (asset_id, version); mappers must produce a version and an ETag
Grace period for old versions after a location changeNoneOld-version URLs return 404 once superseded; players recover by refetching the playlist. Interpreted from “we can break it”
Compatibility of pre-release APIs and URLsNot preservedFree to change internal signatures; v enforcement shipped in TDD 0003

Open questions

  • Token rotation. A single static bearer token is read from the environment at startup. Rotation without a restart (re-reading a file) is not implemented.
  • Batch resolve. The obvious first extension if per-request latency on cold assets matters; it would be a new /v1 path.
  • Signed-URL lifetime for in-flight streams is handled: URLs are refreshed ahead of expiry, rotated in place, and re-fetched once if the origin rejects a read.

TDD 0003: Production-grade HTTP API

  • Status: Accepted
  • Created: 2026-09-19
  • Updated: 2026-09-19
  • Related ADRs: None
  • Related designs: TDD 0001 (the packaging core this hardens), TDD 0002 (builds on this layer)

Summary

A review of the first HTTP implementation found defects that would fail in production: slow clients could exhaust the segment job pool, immutable media URLs ignored their version, the HLS master playlist contained a literal {version}, shutdown ignored SIGTERM, CORS was fixed and incomplete, and there were no request metrics, request IDs, readiness signal, or global load protection. This design records those findings, the changes that fix them, and the operational contract that results.

All of it is implemented and covered by tests. The document is written as a design record rather than a plan: each section states the problem, the decision, and where it lives in the code. The project is unreleased, so several changes deliberately break earlier URLs and internal APIs.

Context

The first implementation (TDD 0001) established correct packaging and streaming, with bounded backpressured range reads, strong ETags, and configurable limits. A read-through of src/http/, src/protocol/, and src/asset.rs against production expectations for an origin behind a CDN produced the findings below.

Goals

  • No small number of slow or stalled clients can take the origin out of service.
  • Immutable URLs can never return different bytes.
  • The service starts, drains, and stops correctly under a container orchestrator.
  • Browser players work without operator guesswork about CORS, and operators can restrict or disable it.
  • Operators can see request rate, errors, latency, load shedding, and stream failures, and can correlate a log line with a request.
  • Per-request CPU and memory cost does not grow with asset size on the hot path.
  • Errors are typed, and error responses cannot be cached as media.

Non-goals

  • TLS termination, viewer authentication, and per-client rate or connection limits. The origin sits behind a proxy or CDN for those (see Remaining gaps).
  • New media capabilities (codecs, encryption, subtitles, multiple renditions).
  • A remote asset catalog. That is TDD 0002.
  • Backward compatibility with pre-release URLs and internal APIs.

Findings and decisions

#FindingSeverityDecision
1A response held a blocking thread and a job slot for its whole lifetime, including time spent waiting on a slow client. The timeout layer stops at response headers, so it did not cover the body. With at most 32 default job slots, 32 stalled clients blocked everyone.HighAsync streaming with per-read slots and an idle timeout
2Init and media URLs are immutable for a year but the v query parameter was ignored, so a CDN could cache new bytes under an old URL.Highv is required and must match
3The HLS master playlist wrote the literal text {version} into the audio playlist URI (push_str on a non-format string). The test only checked the ?v= prefix.HighUse writeln!; assert the real value
4Only SIGINT was handled, so a container SIGTERM killed the process without draining.HighGraceful drain
5CORS allowed only GET/HEAD from any origin and exposed no headers, so Content-Range and ETag were unreadable to scripts and Range/If-None-Match preflights failed. CORS headers were also missing on 408 and 431.HighConfigurable CORS
6Every master playlist and MPD request walked every sample of each track to estimate bandwidth (up to millions).MediumPrecompute at load
7HTTP status was derived by matching error message strings such as "segment does not exist".MediumTyped Error::NotFound
8HEAD on a media segment took a job slot and started the producer.MediumAnswer from metadata only
9If-None-Match matched only an exact single tag. No If-Range, no suffix ranges.MediumConditional and range handling
10Only one metric existed, and there was no request ID or readiness endpoint.MediumObservability
11Each loaded asset keeps about 40 bytes per sample in memory with no total bound.Mediumlimits.max_index_bytes
12Segment header construction (CPU proportional to sample count) ran on an async worker.MediumRun on the blocking pool
13Audio segments were served as video/mp4; HLS BANDWIDTH was the average, not the peak; no release profile; no container image; no request cap.LowCorrect content type, peak plus average bandwidth, [profile.release], Dockerfile, max_concurrent_requests
16The mp4 crate’s per-entry reads hit an unbuffered File, making a warm load of a 60-minute asset take about 2.4 s against a 250 ms budget. Found by the new benchmark harness.HighBuffer the parser input (BufReader); the load now takes about 70 ms
15axum::serve exposes no header-read timeout or connection limit, so a client dribbling headers or opening idle connections held a task and a descriptor indefinitely.MediumIn-process accept loop
14stts/ctts run-length entries were expanded without checking the running total against the (already limited) sample count, so one crafted entry could claim billions of samples. Found while documenting the parser.HighBound the running total before each expansion (src/mp4/parser.rs)

Design

Streaming pipeline

media_segment prepares the segment header on the blocking pool (finding 12), then spawns an async StreamJob task and returns a body backed by a two-item channel.

sequenceDiagram
    participant C as Client
    participant H as media_segment handler
    participant J as StreamJob task
    participant B as Blocking pool
    C->>H: GET segment
    H->>B: prepare_media_segment (header, ranges)
    H->>H: acquire first job slot (503 on timeout)
    H->>J: spawn(job with first slot)
    H-->>C: 200 + streaming body
    loop each chunk
        J->>B: read_range (slot held only here)
        B-->>J: bytes, slot released
        J->>C: send via channel (bounded by idle timeout)
    end

Rules:

  • Slots follow reads, not responses. A job slot (limits.max_segment_jobs) is held only while a source read is in flight and is released before waiting for the client. The first slot is acquired before response headers so overload can still return 503; later reads acquire with the same queue timeout and end the stream with an error if it expires.
  • Every send is bounded. Each channel send is wrapped in limits.response_idle_timeout_ms. A client that stops reading is dropped, which frees its task and buffered chunks.
  • Backpressure is preserved. The channel holds two items, so a slow client stalls its own task, not a thread, and buffered memory stays near 2 x stream_chunk_bytes.
  • HEAD does no source work. Length and range come from prepared metadata, so HEAD neither takes a slot nor starts a task.
  • Abort reasons are counted as idle, client (disconnect), or error.

A test stalls a client for longer than the idle timeout with a single job slot, then confirms a second client can complete and that the stalled stream was cut short and counted.

Immutable URL versioning

Init and media handlers extract ?v= and require it to equal PackagedAsset::version() (the first eight bytes of the source moov SHA-256, hex). A missing or different value returns 404 with Cache-Control: no-store. Playlists and manifests always emit the real version, so a conforming player never sees this error. Playlists are not immutable and carry only max-age=60, so they are not version-checked.

This is a breaking URL change and the reason a bare /hls/x/video/init.mp4 no longer works.

Conditional and range requests

  • If-None-Match accepts a comma-separated list, weak tags (W/"...", compared weakly), and *, across multiple header lines.
  • Range supports a single bytes=a-b, bytes=a-, and the suffix form bytes=-n.
  • If-Range that does not equal the current strong ETag causes the range to be ignored and the full 200 to be sent.
  • Multi-range requests remain 416, a deliberate limitation carried from TDD 0001.

Load-time precomputation

PackagedAsset::load renders the HLS master, both HLS media playlists, and the DASH manifest once and stores them as Bytes. It also stores the version string. Request handlers clone a Bytes (a reference-count increment). Bandwidth is computed once per track from segment payload sizes: HLS declares the peak segment bitrate as BANDWIDTH and the mean as AVERAGE-BANDWIDTH; DASH bandwidth uses the peak.

PackagedAsset::index_bytes estimates resident memory (samples times size_of::<Sample>(), init segments, rendered playlists). AppState::load sums it over the catalog and fails startup above limits.max_index_bytes.

CORS

CORS is a [cors] table validated at load and built into a tower-http layer at startup. The defaults suit a public origin behind a CDN; production deployments should list explicit origins.

KeyDefaultNotes
enabledtrueSet false when a proxy or CDN owns CORS
allowed_origins["*"]Exact scheme://host[:port] values, or ["*"]; cannot mix
allowed_methods["GET", "HEAD"]
allowed_headersrange, if-none-match, if-range, x-request-id["*"] allowed
exposed_headerscontent-length, content-range, accept-ranges, etag, x-request-id["*"] allowed
allow_credentialsfalseRejected with any wildcard
max_age_seconds86400Preflight cache lifetime

The CORS layer sits outside the timeout, header-limit, and load-shedding layers, so their error responses also carry CORS headers and remain readable by scripts. Shape validation runs in the config module, which stores plain strings; header-name and origin parsing runs in http/cors.rs when the layer is built in AppState::load, so an invalid value fails startup rather than a request. (The plain-string design originally kept the config free of HTTP crates for the fuzz target, which no longer compiles it by path.)

Lifecycle and shutdown

stateDiagram-v2
    [*] --> Ready: assets loaded, listener bound
    Ready --> Draining: SIGTERM or SIGINT
    Draining --> Stopping: shutdown_delay_ms elapsed
    Stopping --> [*]: streams finished
    Stopping --> [*]: shutdown_grace_ms elapsed
  • /health is liveness. /ready returns 200 until shutdown begins, then 503.
  • During server.shutdown_delay_ms the service keeps accepting connections so a load balancer can observe /ready and drain.
  • After that, the accept loop stops accepting and waits for in-flight responses, bounded by server.shutdown_grace_ms, after which the process closes the remaining streams and exits.

Load protection

limits.max_concurrent_requests (default 10,000) is enforced by middleware with a non-blocking try_acquire. At the limit the response is 503 with Retry-After: 1 and the vod_http_requests_shed_total counter increments. /health, /ready, and /metrics bypass the limit so probes and scraping keep working under overload. The slot is held until response headers are produced; body streaming is bounded by the job slots and idle timeout described above.

Connection control

http/server.rs runs hyper directly instead of axum::serve:

  • Connection cap. A semaphore of limits.max_connections. When it is exhausted the accepted socket is dropped at once and vod_http_connections_rejected_total increments. The permit travels with the connection task, so it is released however the connection ends.
  • Header-read timeout. limits.header_read_timeout_ms is applied to hyper’s HTTP/1 header read, which restarts for each request on a keep-alive connection. It therefore stops a slow-header client and also closes an idle keep-alive connection that never sends another request.
  • Accept errors. Per-connection errors (reset, aborted, refused) are skipped; anything else, typically descriptor exhaustion, is logged and followed by a one-second back-off.
  • Draining. On shutdown the loop stops accepting and awaits hyper’s graceful shutdown, which closes idle keep-alive connections and lets responses in flight finish. The grace timer in serve still bounds the wait.
  • TCP_NODELAY is enabled on accepted sockets.

Tests drive real sockets: a stalled header block is closed, an idle keep-alive connection is closed, connections beyond the cap are refused and the server recovers when one closes, and shutdown does not wait for an idle connection.

Errors

Error::NotFound maps to 404; everything else unexpected maps to 500, with a generic public message and detail only in logs. Every error a handler produces carries Cache-Control: no-store, and 503 adds Retry-After: 1. 503 responses log at warn (they are expected under load); other server errors log at error.

Observability

Every response carries X-Request-Id. A caller-supplied value of 1 to 128 characters from letters, digits, -, _, . is preserved; anything else is replaced with a generated ID. The ID is attached to the request tracing span, so it appears on every log line for that request. The tracing span is at info level, so request IDs appear on warn and error lines even at the default log level.

Metrics are lock-free atomics indexed by route template and a fixed status list, so cardinality cannot grow with client input:

MetricType
vod_http_requests_total{route,status}counter
vod_http_request_duration_seconds{route}histogram, time to response headers
vod_http_requests_in_flightgauge, cancellation-safe
vod_http_response_bytes_total, vod_source_read_bytes_totalcounters
vod_http_requests_shed_total, vod_segment_queue_timeouts_totalcounters
vod_segment_stream_aborts_{idle,client,error}_totalcounters
vod_log_dropped_lines_totalcounter

Per-route request counts and durations record the time to response headers. Body transfer time is not included; watch the abort counters and the byte counters for that.

Security and limits

New or changed limits, all validated to be greater than zero:

LimitDefaultBehavior at the limit
limits.max_concurrent_requests10,000503 + Retry-After
limits.response_idle_timeout_ms30,000Stream dropped, idle abort counted
limits.max_index_bytes4 GiBStartup fails with the measured size
limits.max_connections10,000New connections closed at accept
limits.header_read_timeout_ms10,000Connection closed
server.shutdown_grace_ms30,000Remaining streams closed
server.shutdown_delay_ms0Accept while /ready reports 503

Also: the parser rejects any stts or ctts entry that would push the expanded total past the stsz sample count, before allocating (finding 14). Error responses never include host paths or internal messages.

Testing

The HTTP tests in src/http/tests.rs drive the real router through tower::ServiceExt::oneshot and cover: real version in playlists; missing and stale v; audio content type; If-None-Match lists, weak tags, and *; single, suffix, and unsatisfiable ranges; If-Range mismatch; HEAD succeeding with every job slot held; header-limit response with CORS headers; request-ID generation, echo, and replacement; concurrency shedding with probes exempt; 503 when no job slot frees; idle-client eviction freeing the slot; CORS defaults, preflight, restricted origins, disabled CORS, and invalid header names failing startup; the index memory budget; readiness flipping; metric counters; and the existing FFmpeg decode of HLS and DASH over a real socket. config tests cover CORS validation, and mp4/parser.rs has a regression test for finding 14.

A live smoke run of the release binary confirmed the response headers, the v check, metrics output, and a clean exit on SIGTERM.

Rollout

Implemented in one change. Because the project is unreleased, no migration is provided: clients must use the versioned URLs the playlists emit, and any test tooling that fetched bare media URLs must add ?v=. Operators should review [cors], set a real shutdown_delay_ms, and size max_index_bytes against container memory (operations guide).

Remaining gaps

  • Per-client connection limits and TLS. The connection cap is global, not per address; per-client limits, TLS termination, and authentication remain the proxy’s job.
  • Vendor validators and browser playback tests. A structural conformance suite now exists (conformance), but Apple’s validator, the DASH-IF tool, and hls.js/dash.js browser tests have not been run. The load and latency budgets are measured by make bench (benchmarks) on one laptop; the reference host is still to do.
  • Structure. The oversized http.rs was split into http/ and the crate gained a library target after this design; see Code organization.

Open questions

  • Should multi-range requests return 200 with the full body, as RFC 9110 permits, instead of 416?
  • Should the request-ID span also be attached to the segment streaming task so its warn/error lines carry the ID? Spawned tasks currently do not inherit the request span.

TDD 0004: Broader MP4 input support

  • Status: Accepted; Phases 1 to 4 implemented
  • Created: 2026-09-20
  • Updated: 2026-09-20
  • Related ADRs: None yet. Two are proposed in Rollout: edit-list timeline mapping and verbatim sample-entry pass-through
  • Related designs: TDD 0001 (the packaging core and its input contract, which this design widens)

Implementation status

ItemStatusNotes
1. Edit listsImplementedsrc/mp4/edit.rs. One edit, optionally after one empty edit; tail trim; leading audio drop
2. Accurate errorsImplementedFragmented input, unsupported codec, and edit-list shape errors name what was found
3. Track selection, multiple audioImplementedNon-media tracks skipped and logged; audio tracks are audio-1, audio-2, and so on
DASH SegmentTimeline t=Implemented
4. Verbatim sample-entry pass-throughImplementedstsd is copied byte for byte; pasp and colr now reach players
5. In-tree container parserImplementedmp4/boxes.rs, tables.rs, codec.rs; the mp4 crate is a dev-dependency only, used to cross-check
6. New codecs, audio-onlyImplementedHEVC, VP9, AV1, HE-AAC, AC-3, E-AC-3, Opus, FLAC, and audio-only assets; see the verification table below
7. Fragmented MP4 inputImplementedDesigned and delivered in TDD 0005
Fixed ftyp brandsImplementediso6 major, iso6 and mp41 compatible

The support matrix below records the state before Phase 1, which is what the design was written against. Since then the rows for edit lists, two audio tracks, timecode and metadata tracks, fragmented input’s error message, QuickTime .mov, and the dropped pasp box have changed; the other rows still hold.

What implementation found

Phase 3, and what was verified where:

FormatFFmpeg decodes the repackaged outputCodec string checked againstChrome 153 (hls.js and dash.js)
HEVC (hvc1)YesUnit tests with reference strings (hvc1.1.6.L93.B0, hvc1.2.4.L120.B0); FFmpeg’s DASH muxer leaves HEVC blankCannot decode on this machine (isTypeSupported is false)
VP9YesMatches FFmpeg’s DASH muxerPlays
AV1YesMatches FFmpeg’s DASH muxerPlays
HE-AAC, HE-AACv2No real file: no HE-AAC encoder was availableSynthetic esds tests for object types 5 and 29Unverified; Chrome reports both codec strings as supported
AC-3, E-AC-3YesMatches FFmpeg’s DASH muxerCannot decode on this machine
OpusYesMatches FFmpeg’s DASH muxerPlays
FLACYesFFmpeg writes flac; segmentor writes fLaC, the sample entry tag. Chrome accepts bothPlays
Audio only, one track and twoYesPlays over HLS and DASH

HEVC, AC-3, and E-AC-3 are therefore verified through FFmpeg and the unit tests but not in a browser, and HE-AAC only through synthetic entries. The README says so.

  • Only configuration boxes were needed per codec. With the sample entry copied, each codec needed its config box read for a codec string and dimensions or channel count, and nothing written. The one place a format still needed special handling was AAC, where mp4a also carries MP3, which shows up as a non-AAC audio object type and is rejected by number.
  • MP3 in MP4 hides in an mp4a entry. The parser rejects it by its object type indication (0x6b), and it replaced the HEVC fixture as the “must be rejected” case.
  • Codec strings must not be assumed longer than four characters. opus, ac-3, and ec-3 are exactly four; a conformance check that rejected shorter strings would have rejected all three.
  • Opus and FLAC report their own channel and rate fields. Opus takes its channel count from dOps and always decodes at 48 kHz; FLAC reads STREAMINFO, because the sample entry’s 16-bit rate field cannot hold 96 kHz.
  • Audio only needed no new protocol concepts. The planner cuts on the first audio track’s samples, the master playlist loses RESOLUTION, and a lone audio track is simply the variant. Several audio-only tracks are renditions of one variant, which follows the HLS authoring pattern and plays in hls.js.
  • hev1 is passed through, not rewritten. The codec string keeps the entry’s tag. Players that follow the standard (Chrome, Firefox) accept either; Apple requires hvc1, so an hev1 file will play in browsers and may not in Safari. Rewriting the tag is valid only if parameter sets never change in-band, which cannot be checked without scanning samples.

Phase 2:

  • Copying is not always right: QuickTime audio entries are rewritten. ffmpeg decodes a .mov whose mp4a entry uses QuickTime’s sound description version 1 (extra fields, esds inside a wave box, a chan box), but Chrome’s Media Source Extensions refuse it (CHUNK_DEMUXER_ERROR_APPEND_FAILED), so a byte-for-byte copy played in the conformance suite and failed in the browser. That one case is rewritten to the ISO layout, keeping the channel count, sample rate, and the esds box; every other entry is still copied verbatim. This is why browser playback is part of the check for this work and ffmpeg alone is not enough.
  • The URL version had to include a format revision. Chrome kept serving the old init segment for the same ?v= URL after the writer changed, because media URLs are immutable and the version came only from a hash of moov. The same would happen behind a CDN on any upgrade that changes the bytes served for an unchanged file. The version now hashes moov together with FORMAT_REVISION in asset.rs, to be bumped whenever init layout, timeline mapping, or playlist format changes.
  • pasp was dropped from every file, not only anamorphic ones. FFmpeg writes a 1:1 pasp into ordinary output, so the old init writer discarded it everywhere; it only mattered for non-square pixels. colr (colour) travels the same path and is now kept too, and the conformance suite compares aspect ratio and colour between source and repackaged output.
  • A second implementation caught nothing, which is the result. The in-tree parser matches the mp4 crate on every sample of seven fixtures, down to the payload bytes at each offset. The crate is kept as a dev-dependency for exactly this comparison.
  • The parser and init writer need no per-codec code. Because the sample entry is copied, only avcC and esds are read, and only for the manifest’s codec string. A new codec in Phase 3 needs its configuration box read and nothing written, unless it too has a container-specific variant browsers refuse.
  • SparseFile shrank to Metadata. With no crate wanting a Read + Seek view, the reader shim and the ftyp fetch went away, so a remote parse is one fewer range request.
  • Subtitle and other non-media tracks are skipped, not rejected. The Phase 1 preflight already set aside every track whose handler is not vide or soun, so the matrix row saying subtitles are rejected has been out of date since then.
  • Tracks are numbered in file order (audio-1 is the first audio track in the file) where the crate returned them by track ID.

Phase 1:

  • The init segment had to drop edts. The design did not say so. Left in, a player applies the edit list a second time on top of the shifted timestamps. Measured with FFprobe: audio started at 0.045 s instead of 0.067 s for the default-edits file, and at 1.024 s instead of 0.545 s for the delayed-audio file. The init writer now clears edts, and a test checks the bytes.
  • Encoder handler names are noise. FFmpeg writes SoundHandler for every audio track, so HLS NAME is Audio {n}, plus the language in parentheses when the file names one, instead of the hdlr name.
  • The offset is real and small. After packaging, both tracks of an FFmpeg-default file start at 0.0667 s where the source’s edit list puts them at 0. The gap between tracks is preserved exactly, and for the delayed-audio file it equals the source’s 0.4787 s. hls.js and dash.js play both files in headless Chrome with no errors. Safari and Firefox are untested.
  • Cover art in MP4 is not a track. FFmpeg stores it as a covr metadata item, so the “single still image” skip rule only matters for QuickTime-style files and has no real-file fixture yet.
  • A timecode track was enough to fail a whole file. Verified before the change (“sample description is not supported”) and covered by a fixture and a test since.

Summary

segmentor accepts a deliberately narrow slice of MP4: progressive files with one H.264 video track, at most one AAC-LC audio track, no edit lists, and one sample description per track. That slice was right for a first core, but it excludes most files that real encoders produce. A plain ffmpeg -c:v libx264 -c:a aac output is rejected today, because ffmpeg writes an edit list by default.

This design lists what is unsupported, ranks it by how many real files it blocks, and proposes a staged plan:

  1. Make ordinary files load: edit lists, track selection, multiple audio tracks, and accurate error messages.
  2. Own the container layer: copy sample entries verbatim into the init segment and parse the boxes we need ourselves, instead of re-serializing through the mp4 crate. This fixes a correctness bug (pixel aspect ratio is dropped today) and is the prerequisite for new codecs.
  3. Add codecs: HEVC, VP9, AV1, HE-AAC, AC-3/E-AC-3, Opus, FLAC, and audio-only assets.
  4. Later: fragmented MP4 input. Encrypted sources and multiple sample descriptions stay rejected until they have their own designs. External data references are permanently rejected.

Nothing here transcodes. Every addition must keep the property that the hot path does no codec work.

Context

How the current support was measured

The input contract in TDD 0001 lists what is rejected. This design tests that list against files produced by a real encoder (ffmpeg n9.0.1, libx264, libx265, libvpx-vp9, SVT-AV1, native AAC, AC-3, libopus) and against the source. Each result below is from starting segmentor serve on the file and reading the load error, unless marked code (read from the source, not run) or unverified.

Compatibility stance

The project is unreleased, so this design is free to change URLs, manifest output, configuration, and error text where that makes the result simpler or more correct. It does not carry compatibility shims. The breaking changes it makes are listed together in Rollout.

Current support matrix

State before Phase 1.

InputResultEvidence
H.264 + AAC-LC, no edit list, moov first or lastWorksFixtures; probe ok.mp4
H.264 + AAC-LC as ffmpeg writes it by defaultRejected: “edit lists are not supported”Probe default.mp4: elst [(144000, 1024, 1, 0)] on both tracks
Same, with no B-framesRejected: same errorProbe nob.mp4: the audio priming edit alone triggers it
Video-only H.264WorksFixture h264-video-only.mp4
10-bit H.264 (High 10)Loads, no warningProbe h10.mp4. Most browsers cannot decode it
Rotation metadata (tkhd matrix)LoadsProbe rot.mp4. Whether players honor it through HLS/DASH is unverified
Two audio tracksRejected: “at most one audio track”Probe twoaudio.mp4; planner.rs:46
HEVC (hvc1)Rejected: “sample description is not supported”Probe hevc0.mp4
VP9, AV1Rejected: same errorProbes vp9.mp4, av1.mp4
AC-3, Opus audioRejected: same errorProbes ac3.mp4, opus.mp4
HE-AAC / HE-AACv2Rejected: “AAC profile other than AAC-LC”Code: parser.rs:154. ffmpeg’s native encoder cannot produce it, so no probe
QuickTime .mov (versioned mp4a entries)Rejected: “mp4a box contains a box with a larger size than it”Probe q.mov. This comes from the mp4 crate
Fragmented MP4 inputRejected, with a misleading message: “parser read outside the fetched metadata regions”Probe frag.mp4. The intended “fragmented MP4 input” error is never reached
Audio-only file (.m4a)Rejected by the edit list first. Would still fail: “exactly one video track”Probe audio.m4a; planner.rs:41
Subtitle track (tx3g)Rejected: “subtitle track”Code: parser.rs:96
Timed-metadata, timecode, cover-art tracksExpected to reject the whole fileUnverified. Common in phone and action-camera files; see Track selection
Encrypted (encv/enca), external data reference, more than one stsd entryRejected on purposeparser.rs:277

A correctness defect, not only a gap

Accepted files can also be packaged wrongly. The init segment is built by cloning moov through the mp4 crate and writing it back (init.rs). The crate only models the boxes it knows, so everything else inside the sample entry is silently dropped. Verified with -vf setsar=4:3:

BoxIn sourceIn init segment
pasp (pixel aspect ratio)10
btrt (bitrate)20 (harmless)

A 4:3-SAR (anamorphic) source is therefore served with square pixels and renders at the wrong aspect. Colour (colr) and HDR boxes (mdcv, clli) would be dropped the same way; the ffmpeg build used did not write colr for this test, so that part is unverified.

Why the mp4 crate is the wall

mp4 0.14 models avc1, hev1, vp09, mp4a, and tx3g sample entries. It does not model hvc1 (the tag Apple requires for HEVC), av01, ac-3, ec-3, Opus, or fLaC, and it has no support for compact sample sizes (stz2). It also cannot parse QuickTime-style mp4a entries. The raw preflight in parser.rs already walks boxes safely on its own, so the project has the tools to stop depending on the crate.

Goals

  • Accept files from mainstream encoders and devices (ffmpeg, HandBrake, phones, screen recorders, editors) without the operator re-muxing them first.
  • Keep A/V sync exact when an edit list is present.
  • Preserve everything in a sample entry that affects rendering (aspect ratio, colour, HDR, codec configuration) by copying it, not re-creating it.
  • Add codecs only where the packager can produce a correct CODECS string and the protocol allows the codec in fMP4.
  • Keep rejecting what would produce questionable output, with an error that says what was found and what to do.
  • Keep every existing resource limit and the no-decode hot path.

Non-goals

  • Transcoding, or rewriting codec bitstreams. That includes converting hev1 to hvc1 by editing the bitstream; changing only the sample-entry tag is discussed in Open questions.
  • Non-MP4 containers (MKV/WebM, MPEG-TS, .mp3, .flac, .ogg). Files in the ISO BMFF family (.mp4, .m4v, .m4a, .mov) are in scope.
  • DRM, encrypted-source pass-through, and subtitle conversion. Each needs its own design.
  • Edit lists that cut or repeat media (more than one non-empty edit) or change the play rate.
  • Compatibility with the current URL layout, manifest text, or ftyp brands. These change where the design says so.

Design

Priorities

Ordered by how many real files each item unblocks, with correctness defects first:

#ItemWhy this orderPhase
1Edit lists (single edit, plus a lead-in empty edit)Blocks ffmpeg’s default output, and therefore most files1
2Accurate errors, including fragmented MP4Cheap; today a user cannot tell what is wrong1
3Track selection: skip non-media tracks, multiple audioBlocks phone and camera files1
4Verbatim sample-entry pass-throughCorrectness defect (pasp); prerequisite for codecs2
5Own the container parsingRemoves the mp4 crate limits (.mov, stz2, new codecs)2
6New codecs, audio-onlyWidens what can be served3
7Fragmented MP4 inputLarge change; a smaller share of sources4

1. Edit lists

What real files contain

ffmpeg’s default output (probe default.mp4) has one elst per track, of the form (segment_duration, media_time = 1024, rate 1). Two encoder facts explain it:

  • Audio priming. AAC encoders emit a first frame of padding. The edit starts at media_time = one frame so that players skip it.
  • B-frame delay. With B-frames the first sample’s composition time is later than its decode time. The edit starts at media_time = that offset so presentation begins at zero.

Other producers add an empty edit (media_time = -1) at the start to delay one track relative to another.

Supported shapes

ShapeBehavior
No elstUnchanged
One non-empty edit, rate 1, media_time ≥ 0Supported
One empty edit followed by one non-empty edit, rate 1Supported
More than these, rate other than 1, dwell edits, or media_time < -1Rejected: “edit list shape not supported: what was found

The problem to solve

tfdt (base media decode time) is unsigned. In a progressive file the edit list lets the first decode time be earlier than the first presentation time. In fMP4 the decode timeline cannot go negative, so the edit cannot simply be applied by subtracting media_time from every timestamp.

Approach: one shared positive offset

Shift every track’s timeline forward by the same amount so nothing is negative, and keep the tracks’ relative offsets exact.

For each track t, let M_t be its edit’s media_time (in the track timescale ts_t) and D_t the duration of its leading empty edit in seconds (zero if none). Define one offset for the whole asset:

O = max(0, max over tracks of ( M_t / ts_t − D_t ))          (seconds)
shift_t = round((D_t + O) · ts_t) − M_t                       (track ticks, ≥ 0)
decode_time'(sample) = decode_time(sample) + shift_t

A sample with media time T then presents at O + D_t + (T − M_t)/ts_t, the same for every track. Relative sync is exactly what the edit list specified, and the whole presentation starts at O (about 67 ms for the ffmpeg default: two B-frame delays at 30 fps, 1024 / 15360 s; about 21 ms if only AAC priming is present) rather than at zero. HLS and DASH players tolerate a small non-zero start.

Two details:

  • Leading audio. Audio samples that end at or before the edit start (the priming frame) are dropped from the track. A partial overlap is kept, so the error is bounded by less than one audio frame (about 21 ms at 48 kHz).
  • Trailing trim. If the edit’s segment_duration ends before the media does, samples that present at or after the end are dropped when they form a suffix in decode order, which is what a plain end-trim looks like. Otherwise the file is rejected as an unsupported shape.

Where it lives

The shift is applied once at parse time, so Sample.decode_time already includes it. The planner, fragment writer, and playlists then need no edit-list knowledge, which keeps the change small. Two consequences:

  • The planner aligns audio to video by comparing shifted decode times, which is what it does today given shifted inputs.
  • DASH SegmentTimeline must state the first segment’s start with t=. Today it omits t and assumes zero (dash.rs), which is wrong once the first segment starts at O.

The asset version is derived from moov and changes automatically.

Alternative considered: carry the elst into the init segment

The init segment could contain the edit list and leave timestamps untouched. This is the most faithful option, but support is uneven across players. It is retained only as a fallback after the Testing matrix shows the shifted approach failing somewhere. This choice is proposed as ADR “edit-list timeline mapping”.

2. Accurate errors

  • Detect fragmented input (moof at top level, or mvex in moov) in the raw preflight before the mp4 crate runs, and return “fragmented MP4 input is not supported”. Today the crate fails first with an unrelated message.
  • Every Unsupported error should carry what was found (the fourcc, the edit-list shape, the track handler) so an operator can act on it. “sample description is not supported” becomes “sample description hvc1 is not supported”.
  • Log each rejected asset once at startup or first request, with the same detail.

3. Track selection

Skipping non-media tracks

Phone and camera files carry tracks that are not audio or video: timed metadata (mebx, gpmd), timecode (tmcd), chapters, and cover art. Today any such track is expected to fail the whole file (unverified, see the matrix).

Policy, decided per track by handler and sample entry:

TrackDefault behavior
vide/soun handler with a supported codecPackaged
Handler is neither vide nor soun (metadata, timecode, text, hint)Skipped, with one track_skipped log line naming the track and reason
vide/soun handler with an unsupported codecRejected, with the fourcc and track id in the error
Video track that is a still image (jpeg/png sample entry, one sample)Skipped as cover art

There is no setting for the third row. A file with an unplayable audio or video track fails to load with an error naming the track, which keeps the rule from TDD 0001 that questionable output fails loudly. If operators later need a lenient mode, it can be added as a separate change.

Multiple audio tracks

Exactly one video track stays required unless audio-only is in scope. Several audio tracks become several renditions:

  • HLS: one #EXT-X-MEDIA:TYPE=AUDIO per track in the same GROUP-ID, with LANGUAGE from mdhd, NAME by position (Audio 1 (eng), because encoders write junk handler names), and DEFAULT=YES on the first only.
  • DASH: one audio AdaptationSet per track, with lang.
  • URL keys: video, then audio-1, audio-2, and so on in file order, for every asset including single-audio ones. One rule, no special case for the first track. The router already takes {track} as a path segment, so no route change is needed.
  • Bandwidth and CODECS in the variant line continue to describe the default audio track.

More than one video track stays rejected. Multi-angle and alternate-resolution sets belong in the mapper’s model, not in one file.

4. Verbatim sample-entry pass-through

Stop re-serializing moov through the mp4 crate. Build the init segment from the source bytes:

  • Take the raw moov bytes already held in SparseFile, and copy the trak boxes for the chosen track with mvhd, tkhd, mdhd, hdlr, vmhd/smhd/nmhd, and dinf as they are.
  • Copy the stsd box byte for byte. This keeps pasp, colr, mdcv, clli, btrt, and any codec configuration box the packager does not otherwise need to understand.
  • Write empty stts, stsc, stsz, stco boxes, as fMP4 requires, and the mvex/trex boxes.
  • Zero the durations, as init.rs does now.
  • Write a fixed ftyp instead of copying the source’s: major brand iso6, compatible brands iso6 and mp41. Source brands such as qt or mp42 describe the original file, not this stream. cmfc is added only after the conformance tools in conformance have been run against the output.

Box sizes and offsets are validated by the same bounded walker the preflight uses. This removes the pasp defect for every codec at once and is the reason new codecs need no per-codec box writers. Proposed as ADR “verbatim sample-entry pass-through”.

5. Owning the container parsing

Replace the mp4 crate’s reader with an in-tree parser for the boxes segmentor uses: mvhd, tkhd, mdhd, hdlr, elst, stsd (entry headers and the codec configuration boxes), stts, ctts (versions 0 and 1), stss, stsc, stsz, stz2, stco, co64. All are small fixed layouts, and the preflight already contains the safe box walker with size checks. The existing fuzz target covers the new code with no changes.

This removes three current failures together: QuickTime mp4a entries, stz2, and codecs the crate does not model. It lands as one step. A partial version that keeps the crate for sample tables would leave two parsers in the tree for a short time and still fail on .mov. Since compatibility is not a constraint, the crate is removed from the runtime dependencies. It may stay as a dev-dependency for cross-checking in tests.

6. Codecs and audio-only assets

Codec table

Each codec needs three things: a sample entry the packager can copy, a codec configuration it can parse to produce an RFC 6381 CODECS string, and permission in fMP4 for the protocol. The protocol column below states the intent, and each row must be confirmed against the current Apple authoring guidance and DASH-IF profiles before it ships.

CodecSample entryConfig boxCODECS stringNote
H.264 (have)avc1avcCavc1.PPCCLLavc3 (in-band parameter sets) can be accepted when avcC is present
HEVChvc1, hev1hvcChvc1.<profile>.<compat>.<tier+level>.<constraints>Apple requires hvc1; see Open questions
VP9vp09vpcCvp09.PP.LL.DD
AV1av01av1Cav01.P.LLT.DD
AAC-LC (have)mp4aesdsmp4a.40.2
HE-AAC, HE-AACv2mp4aesds (AudioSpecificConfig, object type 5 or 29)mp4a.40.5, mp4a.40.29Explicit signaling only. Implicit SBR, where the config says LC but the stream has SBR, cannot be detected without decoding, so such a file is treated as LC, as it always has been
AC-3, E-AC-3ac-3, ec-3dac3, dec3ac-3, ec-3
OpusOpusdOpsopus
FLACfLaCdfLafLaC

MP3 in MP4, ALAC, and subtitles are not in this table on purpose.

Audio-only assets

An .m4a or an MP4 with no video track becomes a valid asset. The change is confined to the planner and the two protocol renderers:

  • The planner cuts fixed windows of segment_duration_ms on audio sample boundaries when there is no video track. Every AAC frame is a random-access point, so no keyframe search is needed.
  • HLS master playlists omit RESOLUTION and list only the audio codec. DASH emits a single audio AdaptationSet.
  • The current “exactly one video track” rule becomes “one video track, or none if there is at least one audio track”.

7. Fragmented MP4 input

Designed and delivered separately in TDD 0005, because it changes the metadata-only read strategy of TDD 0001.

What stays rejected

ConditionDecisionReason
External data reference (dref not self-contained)PermanentIt would let a file redirect reads outside the media root or allowed hosts
Encrypted source (encv/enca, cenc/cbcs)DeferredNeeds key-system, pssh, and senc handling; separate design
More than one stsd entryDeferredNeeds a per-sample description index and several entries in one init segment
Edit lists that cut, repeat, or change rateDeferredWould need concatenation-style planning
Multiple video tracksDeferredSee Multiple audio tracks
SubtitlesDeferredWebVTT/TTML conversion; separate design

Security and limits

The trust boundary is unchanged: files and remote objects are untrusted, and only ftyp, moov, and box headers are read to build the index.

  • Every new parser is bounded. New box parsing uses the existing walker, so sizes are checked against the enclosing box and the source length, and size == 0 and 64-bit sizes are handled the same way.
  • Verbatim copy is copy-only. Pass-through copies bytes the packager has already length-validated. It never interprets sample-entry contents beyond the configuration boxes it must read for CODECS, so a hostile file cannot make it write out of range.
  • Edit lists can inflate work. Entry counts are capped (max_tracks already applies per track; add a fixed cap on elst entries, for example 16, before shape checking).
  • Timeline math uses checked arithmetic. shift_t and rescaling to the track timescale use checked_*, and a shift that overflows u64 rejects the file.
  • Track skipping is not a bypass. Skipped tracks are never read or served, and the sample-count, index-memory, and segment limits are checked on the packaged tracks only.
  • Existing limits apply to new codecs unchanged: max_samples_per_track, max_samples_per_segment, max_segment_bytes, max_index_bytes.
  • Unsupported media tracks still fail the load, so the default remains fail-closed.

Observability

  • Log track_skipped (asset, track id, handler, reason) and edit_list_applied (asset, track id, M_t, D_t, resulting shift_t) at info on load.
  • Add the failing fourcc, edit-list shape, or handler to the existing asset_load_failed event, so the reason is queryable and not only in a message string.
  • Count load failures by reason in the existing resolver/load metrics, using a small closed set of labels (edit_list, codec, fragmented, track, other) so label cardinality stays fixed, as in metrics.rs.

Testing

Fixtures. Extend generate.sh. The current fixtures are all made with -use_editlist 0, which is why the largest gap went unnoticed. Add:

  • ffmpeg default output (edit list, B-frames), and the same without B-frames
  • a file with an empty leading edit and a trimmed tail
  • two audio tracks with different languages
  • a file with an extra timecode or metadata track, and one with cover art
  • non-square SAR and tagged colour (pasp, colr)
  • HEVC (hvc1), VP9, AV1, AC-3, Opus, HE-AAC where an encoder is available, and an .m4a
  • a QuickTime .mov
  • a fragmented MP4, to lock in the accurate error

Unit tests.

  • Edit-list mapping: table-driven cases for the shift formula, including the ffmpeg defaults and empty leading edits, with sync between tracks asserted exactly and tfdt never negative.
  • Leading-audio drop and trailing-trim rules, and every rejected shape.
  • Pass-through: the stsd bytes in the init segment equal the source’s, byte for byte. Golden test with pasp/colr.
  • CODECS string derivation per codec, compared with FFprobe’s codec_tag_string and profile output as the existing parser tests do.

Round-trip checks. Extend the FFmpeg decode suite: for each new fixture, decode through HLS and DASH and compare frame and sample counts and first-frame timestamps with the source. For edit-list files, assert A/V start times agree with the source’s, to within one audio frame.

Compatibility, currently not covered. The browser playback suite is still pending. Edit-list handling and every codec depend on player behavior, so this work should not be called done without it. Minimum matrix: Chrome and Firefox through hls.js and dash.js, Safari native HLS, and the Apple HLS validator and DASH-IF conformance tool from the conformance page. The demo player in demo/ can serve as a manual harness.

Fuzzing. The seeded fuzz target should include the new parsers and the edit-list mapper. Add seeds from the new fixtures.

Performance. Re-run make bench before and after. Verbatim pass-through should reduce init-segment work; the edit-list shift adds one addition per sample at load.

Rollout

PhaseContentsExit criteria
1Edit lists (item 1), accurate errors (2), track selection and multiple audio (3), DASH SegmentTimeline t=; new fixturesffmpeg-default files play with correct sync in the browser matrix. No regression on existing fixtures. Matrix table updated
2Pass-through init writer (4), in-tree parser (5)pasp/colr preserved; .mov and stz2 parse; the mp4 runtime dependency removed; fuzz target updated
3Codecs and audio-only (6), one codec at a time in the order HEVC, HE-AAC, Opus, AC-3/E-AC-3, VP9, AV1, FLACEach codec passes decode round-trip and the protocol validators before it is listed as supported
4Fragmented MP4 input (7)Own design accepted first

Breaking changes. Made deliberately, and none are shimmed:

  • Track URL keys become video and audio-N (was audio). The asset version changes, so every cached URL refreshes once.
  • The DASH SegmentTimeline states the first segment start with t=.
  • The init segment’s ftyp is fixed, and its stsd is copied verbatim, so pasp, colr, and HDR boxes now appear where they were dropped.
  • Presentation may begin slightly after zero for files with edit lists (see Approach).
  • Error text and the asset_load_failed event change to include what was found.
  • The mp4 crate leaves the runtime dependencies.

Each phase is still independent. Phase 1 can ship without Phase 2, and a codec can be reverted by removing it from the accepted table.

Proposed ADRs, to be written when their phase starts: “edit-list timeline mapping” (item 1, including the fallback) and “verbatim sample-entry pass-through” (item 4).

Update the matrix in TDD 0001 and media-pipeline as each phase lands, so the input contract has one source of truth.

Open questions

  • Edit lists in players. Does the shifted-timeline approach behave identically in Safari, Chrome, and Firefox, including seeking to the first segment? What does each do with a first tfdt greater than zero when the DASH manifest and HLS playlist both start there? If any player misbehaves, is carrying the elst in the init segment a better fallback for that player?
  • hev1 versus hvc1. Settled: the entry’s tag is passed through and not rewritten (see the Phase 3 findings). Whether to warn when an hev1 asset is served is open.
  • Ordering of new codecs. The order above is by expected usage, but HEVC and AV1 have the widest player-support variance. Should AV1 wait for evidence that target players decode it through the protocols in use?
  • Rotation and HDR signaling. tkhd matrix and HDR boxes will be preserved by pass-through, but HLS and DASH have their own attributes (VIDEO-RANGE, for example). Should the manifest advertise them, and how much of this is in scope here?
  • Lenient track handling. Should an operator be able to serve the tracks segmentor understands from a file that also has an unsupported audio track (for example AC-3 next to AAC), and if so, per asset or globally?

TDD 0005: Fragmented MP4 input

  • Status: Accepted; implemented, including the two follow-ups below
  • Created: 2026-09-20
  • Updated: 2026-09-20
  • Related ADRs: None
  • Related designs: TDD 0001 (the metadata-only read strategy this changes), TDD 0004 (phase 4, which this delivers)

Implementation status

Implemented as designed, with the open question about a truncated final fragment still open. Several things the design did not anticipate, two of them real bugs that tests caught, are recorded below.

What implementation found

  • The timeline origin must be found in seconds. The first version subtracted the smallest first decode time across tracks in raw ticks. Video at 15,360 ticks per second and audio at 48,000 are not comparable, so a file starting at 100 seconds (1,536,000 video ticks, 4,800,000 audio ticks) moved its audio by the wrong amount. The offset fixture caught it. The origin is now the track whose first sample is earliest in seconds, converted into each track’s ticks and rounded down so no first sample goes below zero.
  • Overlapping fragments had to be rejected, not just gaps kept. The mutation test found a panic within seconds: a corrupted tfdt put a fragment before the previous one, decode times stopped being monotonic, and the planner subtracted a larger time from a smaller one (a panic in debug builds, a silent wrap to a huge duration in release). A progressive file cannot do this, since stts is cumulative. A fragment that starts before the previous one ended is now refused, and the planner’s subtraction is checked as a second line of defence. After the fix, about 114,000 corrupted inputs across the fragmented fixtures ran with no further panic.
  • The URL version has to cover the fragments. After the feature shipped, two different fragmented files that differed only inside their moof boxes were found to get the same v, because the version hashed moov alone. That is enough for a progressive file, whose moov holds every sample table, but a fragmented file’s moov is nearly identical across recordings from one encoder, so a CDN would have kept serving old immutable segments after the content was replaced. The version now hashes moov and every moof (SourceIdentity::metadata_sha256), and a test loads two such files and requires different versions.
  • A run in another track cannot be iterated. The legacy base-offset rule forces every traf in a moof to be measured, including other tracks’. A trun whose entries take no bytes can claim four billion samples, so a run that is measured but not kept computes its extent by multiplication. A test with u32::MAX samples in a track nobody asked for returns immediately.
  • FFmpeg’s cmaf flag implies negative composition offsets. That fixture’s offsets are the progressive file’s shifted by a constant. The equivalence test therefore compares how samples differ from the first, and compares absolute values only for variants that do not shift them.
  • FFmpeg normalises the start time to zero, so a fixture starting at 100 seconds cannot be made with it. generate-variants.sh adds 100 seconds to every tfdt of the plain fragmented fixture instead.
  • Remote cost measured. A three-fragment file loads from a mock origin in nine requests or fewer, and the packaged output is byte-identical to the local file’s. The windowed walk is what keeps it from being one request per box header.
  • Browsers. All six layouts play in Chrome through hls.js and dash.js; the 100-second file plays from 0.067 s, as a progressive file with B-frames does.

Follow-ups: parallel discovery and truncated tails

The two limits the design left open are now handled.

Parallel discovery through sidx. Measured against a mock origin with injected latency, the sequential walk costs one request per fragment and about 19 ms per fragment at 15 ms of latency (200 fragments: 3.8 s), which is over a minute for an hour of one-second fragments. With a sidx and eight fragments fetched at once, 160 fragments at 20 ms took 0.56 s where the sequential walk took 3.83 s, with 161 requests and never more than eight in flight. The design choices:

  • The sidx is a hint about where to look, never a source of facts. Each subsegment it lists is verified by walking its own boxes from its start to exactly its end, and it must contain a moof; a mismatch abandons the fast path and the walk continues box by box.
  • The jump happens only when the sequential walk arrives exactly at the start of the region the sidx describes. A first_offset that skips over boxes would otherwise lose whatever fragments sit in the gap; they are walked instead. The fast path found one such bug during development (fragments found in a gap were replaced instead of extended), which the test for that case caught.
  • A subsegment may hold several moof/mdat pairs; all are found. A sidx covering only the start of the file leaves the rest to the walk.
  • Hierarchical references, zero-sized references, fewer than four references, and more than max_fragments are not used. A sidx per fragment, as CMAF chunk writers emit, has one reference each and stays sequential.
  • Real sidx boxes are messy: FFmpeg itself warns that its sidx is incorrect when tracks are written in separate moof boxes. That is why verification and the fallback matter more than the speed-up.
  • A test corrupts random bytes of the sidx 400 times and requires that discovery either finds exactly the fragments a plain walk finds or falls back; it never found a different set and never failed a load.
  • limits.metadata_concurrency (default 16) bounds the fan-out, on top of the remote reader’s own max_inflight_reads.

Truncated tails. A fragmented file that ends inside a moof or mdat is refused by default, with a message that says what happened and names the setting. limits.tolerate_truncated_tail = true serves the complete fragments before the cut instead:

  • Only after at least one whole fragment; a file with none is refused either way, as is a cut moov or a cut progressive file.
  • A cut mdat takes the moof before it too, because that moof describes samples that are not all there.
  • Only a box that declares more bytes than remain counts as cut. A malformed header, such as a size below eight, is corruption and is refused.
  • The load logs truncated_tail_dropped with the bytes and fragments left out.
  • The URL version covers exactly the fragments served, so it changes as a growing file completes each fragment, and a CDN never mixes two states of the file. This is not live support: the playlists are built once per load, and a file being appended to needs to be reloaded to show new fragments.

The remaining limit is that a very large remote file with no sidx still costs a request per fragment, one at a time. The choice there is between re-muxing at ingest and caching the discovered metadata per version, and neither is needed until a real workload shows it.

Summary

Accept MP4 files that are already fragmented (moov with mvex, then moof and mdat pairs): recorder output, CMAF files, and anything written with -movflags frag_keyframe+empty_moov. Today they are rejected with a message telling the operator to re-mux.

The sample index for such a file is not in moov, whose tables are empty. It is spread across the moof boxes, one per fragment. This design reads every moof, builds the same MediaIndex a progressive file produces, and leaves the rest of the pipeline untouched: planning, init segments, fragment writing, playlists, and streaming already work from that index and from byte ranges into the source.

Context

Everything downstream of parsing consumes MediaIndex: per-sample offset, size, decode time, duration, composition offset, and sync flag. Media segments are produced by reading sample byte ranges out of the source, and the source’s own mdat layout is irrelevant. A fragmented source is therefore a different way of finding the same facts, not a different pipeline.

Two things make it a real change and not a parser addition:

  • The read strategy. TDD 0001 fetches moov and nothing else. A fragmented file’s index is in moof boxes scattered through the file, one per fragment, so a file with 1,800 fragments needs 1,800 small reads, and for a remote origin each is a request.
  • The timeline. Fragmented files often start at a large decode time (recordings stamped with wall-clock time, or a segment cut from a longer stream), and their moov durations are zero.

Goals

  • Index fragmented files into the same MediaIndex, so packaging, playlists, and streaming are unchanged.
  • Support the layouts real writers produce: several tracks in one moof or a moof per track, explicit and default-is-moof base offsets, all trun field combinations, and signed composition offsets.
  • Bound the cost of discovery for both local and remote sources, and refuse files that exceed it with a message that names the limit.
  • Produce output indistinguishable from packaging the same media as a progressive file.

Non-goals

  • Live or growing files. The index is built once from a file that is complete.
  • Files that mix samples in moov with fragments. They are rejected.
  • Using sidx or mfra to avoid reading every moof. See Alternatives.
  • Encrypted fragments (senc, saiz, saio), which remain rejected at the sample entry.

Design

Recognising the input

A file is fragmented when moov contains mvex. Top-level moof boxes without mvex are invalid. A fragmented file with no moof at all has no media and is rejected. A fragmented file whose tracks also list samples in moov (a non-empty stsz) is rejected as mixed.

Reading the fragments

Metadata::fetch already walks the top-level box headers to find moov. It now also keeps every moof box whole, with its offset.

The walk reads through a small window instead of 8 bytes per header. A read of 8 KiB at a box’s offset usually contains the whole moof (a 2-second HD fragment has one to three kilobytes of trun entries) and the header of the mdat that follows it, so a fragment costs about one read, not three. A moof larger than the window is read again for its remainder. Everything else at the top level (styp, sidx, emsg, prft, free, mfra, mdat) is skipped by its header.

Limits, all checked before allocating:

LimitDefaultApplies to
max_fragments (new)20,000moof boxes in one file; about 11 hours at 2 s fragments
max_metadata_bytes64 MiBmoov plus every moof, now summed
max_samples_per_track2,000,000samples across all fragments of a track

The existing cap of 4,096 other top-level boxes stays, and mdat boxes are counted with the fragments, since every fragment has one.

The cost for a remote source is roughly one request per fragment, sequentially, because each box’s offset comes from the size of the one before. That is the price of this design; see the alternatives.

Building samples

For each track, every traf naming it is read in file order:

  • tfhd supplies the track ID, optional defaults (duration, size, flags), and the base data offset. The base is the explicit base_data_offset if present, else the start of the moof when default-base-is-moof is set, else the legacy rule: the start of the moof for the first traf, and the end of the previous traf’s data after that. The legacy rule needs the previous traf’s end whichever track it belongs to, so every traf in a moof is measured.
  • tfdt sets the decode time of the fragment. When absent, decoding continues from the end of the previous fragment of that track.
  • trun lists samples. Each field (duration, size, flags, composition offset) falls back to tfhd, then to the trex defaults in mvex. The optional first-sample flags override the flags of the first sample. Composition offsets are unsigned in version 0 and signed in version 1. The data offset is relative to the base; without one, a run continues where the previous run in the traf ended.
  • Sync is the absence of the non-sync bit (0x10000) in the sample’s flags.

Every sample is checked as a progressive one is: its range must end inside the source, and the running count must stay within max_samples_per_track. A trun cannot claim more entries than its bytes hold, and a trun whose entries take no bytes (all fields defaulted) is still bounded by that count limit before it is expanded.

A track with no samples at all is an error naming the track, for progressive and fragmented files alike.

Normalising the timeline

Two properties of fragmented files need handling after the samples exist:

  • Start time. The first decode time of a fragmented file is often not zero. The playlists and the DASH timeline assume a presentation that starts near zero, and a bandwidth estimate divided by an end timestamp of hours would be wrong. After edit lists are applied, the smallest first decode time across tracks is subtracted from every sample. The relative timing of tracks, and every duration, are unchanged; only the origin moves. Progressive files are not normalised, because their small positive start is deliberate (see TDD 0004).
  • Durations. mvhd, mdhd, and tkhd durations are usually zero in a fragmented file. A track’s duration is taken from its last sample instead.

Gaps or overlaps between fragments are kept as they are, because every sample carries its own decode time and the fragment writer writes it. A playlist duration absorbs a gap into the segment before it, which is a small inaccuracy and not a playback failure.

What does not change

The init segment writer already copies from moov, empties the sample tables, and writes its own mvex, so a fragmented source’s mvex, mehd, and moof boxes are simply not copied. The planner, fragment writer, playlists, manifest, and streaming path read the index and the source and need no change. Edit lists in a fragmented file’s moov apply exactly as they do to a progressive one.

Alternatives considered

  • Use sidx to find the moof boxes. A sidx lists every fragment’s offset and size, which would let the reads run in parallel, and CMAF files usually have one. It does not carry per-sample sizes, so every moof still has to be read, and files without a sidx need the sequential walk anyway. Parallel discovery would cut load time for remote sources with many fragments. It is a follow-up with its own trade-offs (a second code path, and trusting the sidx over the file), not a prerequisite.
  • Re-mux on ingest. Tell operators to convert the file, as the error does today. Cheap for us, and it moves work and storage to every user with a fragmented source.
  • Index lazily, per fragment. Read a moof only when its segment is requested. It fits the “work proportional to the request” idea, but playlists need every segment’s duration up front, so the index must be complete before the first playlist is served.

Security and limits

The trust boundary is unchanged. Every count read from a moof is checked against the bytes present before allocation, offsets use checked arithmetic (a data offset is signed), and the limits above bound the number of fragments, the metadata bytes, and the samples. The fragment walk is bounded by the source length and the box count. A hostile trun cannot allocate: entries take at least the bytes they declare, and entry-less runs are bounded by the sample limit before expansion.

Observability

  • The asset_loaded event gains media.fragments for fragmented sources.
  • Load failures for exceeding a fragment or metadata limit name that limit.

Testing

  • Equivalence with a progressive file. Fragmented fixtures are made by remuxing the existing progressive fixture with -c copy, so they hold the same packets. A test parses both and compares every sample: size, duration, composition offset, sync flag, and the payload bytes at each offset. Decode times must match too once the origin is normalised.
  • Layouts. One fixture per writer behaviour: both tracks in one moof, a moof per track (CMAF), no default-base-is-moof (explicit base offsets), a sidx present, and a start time of 100 seconds.
  • Unit tests for trun field combinations and defaults, first-sample flags, version 1 negative offsets, legacy base offsets across several trafs, missing tfdt, and every rejection (mixed samples, moof without mvex, no fragments, over the fragment limit, truncated boxes, a run that claims more entries than it holds).
  • The existing suites. The conformance suite gets the fragmented fixtures, so every fragmented source is audited as HLS and DASH, decoded by FFmpeg, and compared with its source.
  • Mutation. The corrupted-metadata test also corrupts moof bytes.
  • Browsers. Fragmented fixtures play in Chrome through hls.js and dash.js.

Rollout

One change. Fragmented files that used to fail now load; nothing that worked changes, apart from the error for a track with no samples. max_fragments is a new key in [limits], documented in operations.

Open questions

  • Remote sources with many fragments. Sequential discovery costs one round trip per fragment. Is 20,000 the right default cap for remote origins, or should remote sources have a lower one until discovery is parallel?
  • Growing files. A file still being written has a moov and a partial last fragment. Should a truncated final moof be dropped instead of failing the file?

TDD 0006: Trick play, subtitles, and adaptive renditions

  • Status: Draft
  • Created: 2026-09-21
  • Updated: 2026-09-21
  • Related ADRs: None
  • Related designs: TDD 0002 (the mapper interface this extends), TDD 0004 (the input this builds on)

Summary

Three product gaps were chosen for the next stage, in this order of delivery:

  1. HLS I-frame playlists, for scrub previews and fast-forward. No mapper change, no decoding.
  2. Sidecar WebVTT subtitles, listed by the mapper and served by segmentor.
  3. Adaptive renditions, several source files per title served as one adaptive stream, listed by the mapper.

DRM is required but deferred to its own design, after these three. Section DRM records what these designs must not make harder.

The mapper changes are additive: a mapper that sends none of the new fields behaves exactly as today, and a segmentor that does not know the fields ignores them.

Context

An asset is one MP4 today: one video track, some audio tracks, and a single video variant. That gives players no way to switch quality on a slow connection, no captions, and no fast scrubbing, which are the things a viewer notices first. The packaging pipeline already has what each feature needs: the sync flags of every sample, a per-request fragment writer, and a mapper that names where the media is.

Goals

  • Scrubbing that costs no decoding and no extra storage.
  • Captions in HLS and DASH from files the mapper already hosts.
  • One asset ID that plays as an adaptive stream when the mapper lists several renditions, and as today when it lists one.
  • Every new input is bounded and validated like a media file: size limits, the [remote_media] policy for URLs, and errors that name what was wrong.

Non-goals

  • Transcoding to make renditions. They are separate encodes that already exist.
  • DASH trick-mode adaptation sets. Only HLS I-frame playlists are built.
  • Subtitle formats other than WebVTT, and converting text tracks inside the MP4.
  • Live or dynamic manifests.
  • DRM. See below.

1. HLS I-frame playlists

Implemented as designed. The conformance suite checks, for every fixture with video, that the playlist lists the source’s keyframes and that each fragment decodes to one picture. The first version trusts stss, as described below; the open question about IDR pictures remains.

Output

The master playlist gains an #EXT-X-I-FRAME-STREAM-INF line pointing at video/iframes.m3u8. That playlist has #EXT-X-I-FRAMES-ONLY and one entry per keyframe. Each entry is its own small resource, /hls/{asset}/video/iframes/{n}/media.m4s: a fragment holding that one sample, with the video track’s existing init segment.

One fragment per keyframe is deliberate. A byte range into the ordinary media segment would not work for fMP4, because the segment’s single moof describes every sample in it, so a range covering only the I-frame has no metadata for the player. The fragment writer already produces a moof and mdat for any run of samples on request, so a one-sample fragment is a TrackSegment covering one sample and no new machinery.

  • EXTINF is the time from this keyframe to the next one, or to the end.
  • BANDWIDTH is the peak I-frame bitrate: the largest keyframe in bits over the interval it represents. AVERAGE-BANDWIDTH uses the totals. CODECS is the video codec alone, and RESOLUTION is the video’s.
  • Only tracks with a video track and at least one sync sample get one.
  • The playlist is rendered at load with the others, from the sync flags already in the index. It costs roughly a hundred bytes per keyframe.

Keyframes that are not independently decodable

stss marks sync samples, and a sync sample is not always an IDR picture: an HEVC open-GOP file marks CRA pictures too, which cannot be decoded without earlier frames. Telling them apart needs a look at the NAL unit type. For H.264 the sync table normally marks IDR pictures and the risk is small, so the first version trusts stss and records the gap. See Open questions.

Testing

Each I-frame resource, prefixed with the init segment, must decode to exactly one frame with FFmpeg, for every fixture with video. Scrubbing itself is a Safari and tvOS behaviour and is checked by hand.

2. Sidecar WebVTT subtitles

Implemented as designed, with two refinements: an http subtitle origin must support ranged requests (it is opened like media, so it gets the same [remote_media] and redirect protection), and the size and count limits are limits.max_subtitle_bytes, limits.max_subtitles_total_bytes, and limits.max_subtitles. The version covers subtitle content; a mapper must still change its own version when a caption changes, because that is what triggers a reload. Checked in headless Chrome with hls.js and dash.js: cues appear at the right times, including on a file whose edit lists shift the timeline by 67 ms. The shift is the edit lists’ shared offset O, not a track’s own delay (a late-starting video is already part of the presentation the cues were written against), and a fragmented file is not shifted. Not checked in Safari.

Mapper answer

An optional subtitles list, each entry:

{ "language": "en", "label": "English", "default": true, "forced": false,
  "location": { "type": "http", "url": "https://origin.example.net/subs/movie.en.vtt" } }

location is a file or http location exactly as for media, and an http one is subject to the same [remote_media] policy: allowed hosts, no private addresses, no redirects. language is a BCP 47 tag and unique within the asset. default and forced are optional.

What segmentor does with a file

The file is fetched when the asset loads and is held in memory, so requests never touch the origin:

  • Validation. UTF-8, at most limits.max_subtitle_bytes (default 2 MiB) each and a limit in total, and it must begin with WEBVTT. Anything else fails the asset load with a message naming the language.
  • Timeline correction. An asset whose edit lists trim encoder delay is served on a timeline O later than the source’s clock (see TDD 0004), so a cue authored against the source would appear early by O. Cue timing lines are shifted by O when the asset loads. A track’s own delay is not part of O, and a fragmented file, whose timeline starts at zero, is not shifted. Everything else in the file is passed through unchanged.
  • Version. The asset version covers the subtitle content, so changing a caption gives new URLs.

Output

  • HLS. #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs" per language with LANGUAGE, NAME, DEFAULT, AUTOSELECT, and FORCED, and SUBTITLES="subs" on the variant. Its playlist subtitles/{language}/index.m3u8 lists the whole file as a single segment subtitles/{language}/sub.vtt with an EXTINF of the asset duration, which is valid for VOD.
  • DASH. A text AdaptationSet with lang, mimeType="text/vtt", and a Representation whose BaseURL is subtitles/{language}/sub.vtt.

Subtitles are not tracks of the MP4, so they are held beside the tracks and are not a TrackKey.

Testing

Cue-shifting unit tests, including hour rollover and settings after the timing; validation rejects for every bad file; SSRF cases through the existing policy; and Chrome through hls.js and dash.js showing a cue in video.textTracks at the shifted time.

3. Adaptive renditions

Mapper answer

Instead of a single location, an answer may give renditions:

{ "renditions": [
    { "id": "1080p", "location": { "type": "http", "url": "https://origin.example.net/m/1080.mp4" } },
    { "id": "720p",  "location": { "type": "http", "url": "https://origin.example.net/m/720.mp4" } },
    { "id": "audio-en", "language": "en", "location": { "type": "http", "url": "https://origin.example.net/m/en.m4a" } } ] }

location and renditions are mutually exclusive, and a single-location answer is one implicit rendition, so nothing changes for existing mappers. A rendition with video is a video rendition; one with only audio is an audio rendition, which is how separate audio files, and several audio languages, are supplied. id is a short URL-safe label, unique in the asset.

Structure

Each rendition is loaded as its own packaged asset: its own index, plan, init segments, and version, so all the existing parsing, limits, and caching apply to it unchanged. A composite asset holds the list and answers for the master playlist and the DASH manifest. Track keys stay the free-form names the routes already accept, so no route changes: video-{id} for a video rendition’s video track (video when there is one), and audio-{n} for the shared audio tracks. The composite maps each key to a rendition and a real track.

Audio

Audio is a single shared group, so that switching video quality never switches or restarts audio. It comes from the audio renditions when there are any, and otherwise from the audio of the first video rendition that has some. The audio of the other video renditions is ignored.

Alignment

Players switch between renditions at segment boundaries, so every video rendition must have the same segments. The composite refuses the asset unless all video renditions have the same number of segments with the same start times, within one sample of the coarser rendition, and the same start offset. The error names the two renditions and the first segment that differs. Keyframe placement in the sources decides this, so it is a property of the encodes and not something segmentor can repair.

Output

  • HLS. One #EXT-X-STREAM-INF per video rendition, sorted by bandwidth, each with its own BANDWIDTH, AVERAGE-BANDWIDTH, CODECS, and RESOLUTION, all pointing at the shared audio group.
  • DASH. One video AdaptationSet with a Representation per rendition (id="video-{id}") and segmentAlignment="true".
  • I-frame playlists (section 1) are emitted for the lowest-bandwidth rendition only, since scrubbing does not need more.

Limits and failure

limits.max_renditions (default 8). Each rendition counts toward max_index_bytes like any asset. Loading is all or nothing: if any rendition fails, the asset fails with the rendition named, so a viewer never gets a ladder with a silent gap. The composite’s version hashes the mapper’s version and every rendition’s version.

Testing

Fixtures made from one source at different resolutions and bitrates with a fixed keyframe interval, so they align; a fixture with misaligned keyframes for the refusal; an audio-only rendition. A conformance case, and Chrome switching level in hls.js and dash.js with playback continuing.

DRM (later)

Not designed here. To keep it possible, these designs keep init segment generation per rendition and per track, and keep rendition and subtitle handling separate from how a sample entry is copied, because encrypted sources add sinf, tenc, and pssh boxes to the init segment and senc to fragments, and need key-system signaling in HLS and DASH. Encrypted sample entries (encv, enca) stay rejected until that design is accepted.

Security and limits

Every new input goes through an existing gate: subtitle and rendition URLs through the [remote_media] policy, sizes through the new limits, counts through max_renditions and the subtitle limit, and parsing through the same bounded, checked-arithmetic style as the media parser. A hostile subtitle file can fail its asset and cannot allocate past its limit. I-frame resources are generated from the existing index and add no new input.

Observability

Load events gain the number of renditions, subtitles, and keyframes served. Refusals (misaligned renditions, bad subtitles) are asset_load_failed with the specific reason.

Rollout

Three independent changes, in order. The first needs no mapper change and can ship alone. The second and third extend TDD 0002 and the mapper API reference, additively. Each ships with its own tests and documentation, and none changes the output for an asset that does not use it.

Open questions

  • IDR versus other sync samples. Should the I-frame playlist inspect the NAL unit type of each keyframe, so open-GOP HEVC files do not list pictures that cannot decode alone?
  • I-frame streams per rendition. Is one stream, from the lowest rendition, enough for Apple’s seek bar, or should each rendition have one?
  • Default subtitle track. When the mapper marks none as default, should segmentor pick none, or the first?
  • Other subtitle formats. SRT is common in source libraries; converting it to WebVTT is small, but it should be a decision and not a surprise.

TDD NNNN: Title

  • Status: Draft
  • Created: YYYY-MM-DD
  • Updated: YYYY-MM-DD
  • Related ADRs: None

Summary

Briefly describe the design and the user-visible outcome.

Context

Describe the problem, constraints, and relevant existing behavior.

Goals

  • Goal

Non-goals

  • Non-goal

Design

Describe components, data flow, interfaces, storage, concurrency, and failure behavior.

Security and limits

Describe trust boundaries, resource limits, and validation.

Observability

Describe logs, metrics, and traces needed to operate the feature.

Testing

Describe unit, integration, compatibility, and performance checks.

Rollout

Describe implementation stages and migration or rollback concerns.

Open questions

  • Question

Implementation guide

This part of the book explains how segmentor works inside, module by module, so a new contributor can find the right file, understand its invariants, and change it safely. The technical designs explain why the system is shaped this way; this guide explains how the code does it.

PageCovers
This pageBig picture, lifecycles, data model, concurrency, conventions
Media pipelinesource, mp4, media, segment, fmp4: from bytes on disk to fragments
Registry and resolversresolver, registry, remote source: asset lookup, caching, and loading
Protocols and assetsprotocol (hls, dash, Presentation) and asset: playlists, manifests, and the loaded-asset object
HTTP serverhttp/: router, middleware, handlers, streaming, ranges, errors
Runtime supportconfig/, error, observability/ (logging, metrics), cli/, lib/main
TestingTest layers, fixtures, fuzzing, make ci
Code organizationReview of the source layout and a proposed reorganization

What the program does

segmentor is a video-on-demand origin. Given an ordinary MP4 file, it answers HLS and DASH requests by repackaging the existing encoded audio and video into fragmented MP4 (fMP4) on the fly. It never decodes or re-encodes media. The expensive facts about a file (where every frame is, when it plays, which frames are keyframes) are computed once when the asset is loaded. A segment request then only builds a small header and copies the requested byte ranges from the file.

It has two commands:

  • serve --config <file> runs the HTTP origin.
  • package --input <mp4> --output <dir> writes the same init and media segments to disk. It is a development and test tool that shares all packaging code with the server.

Module map

flowchart TD
    main[main.rs] --> lib[lib.rs run]
    lib --> cli[cli/<br/>serve, package]
    cli --> config[config/]
    cli --> obs
    cli --> http
    http[http/<br/>router, handlers, streaming] --> registry[registry/<br/>caches, single flight]
    registry --> resolver[resolver/<br/>static, mapper]
    registry --> asset
    http --> obs[observability/<br/>logging, metrics]
    http --> config
    asset[asset.rs<br/>PackagedAsset] --> protocol
    asset --> mp4
    asset --> segment
    asset --> fmp4
    asset --> source
    protocol[protocol/<br/>hls, dash, Presentation] --> media
    mp4[mp4/parser.rs<br/>sample index] --> media
    mp4 --> source
    segment[segment/planner.rs<br/>segment plan] --> media
    fmp4[fmp4/<br/>init and fragment writers] --> media
    fmp4 --> segment
    fmp4 --> source
    source[source/<br/>MediaSource, LocalMediaSource]
    media[media/index.rs<br/>MediaIndex, Track, Sample]
    config --> error[error.rs]

protocol does not depend on asset: asset builds a read-only Presentation (tracks, plan, version) once at load, calls the renderers with it, and stores the rendered text.

The crate is a library plus a ten-line binary. Almost everything is pub(crate); the public surface is run() and a hidden fuzzing module used by the fuzz target (see Code organization).

Startup lifecycle

  1. main calls lib::run, which dispatches through cli::run and parses serve --config by hand.
  2. Config::load reads and validates the TOML file, resolves the media root and every asset path to canonical absolute paths, and rejects anything outside the root.
  3. observability::logging::init installs the non-blocking tracing subscriber.
  4. http::serve calls AppState::new, which builds the CORS layer, the resolver, the remote-media client, and the asset registry. Nothing is loaded yet.
  5. With the static catalog, preload() then loads every configured asset (bounded by limits.max_startup_parses): open the source, mp4::parse, segment::plan, one fmp4::write_init_segment per track, compute the version, and render all playlists from a Presentation view. Total index memory is checked against limits.max_index_bytes. A bad asset stops startup. With a mapper, assets load on their first request instead.
  6. The process binds the listener and logs service_ready.

Request lifecycle

Layers wrap the router. The request passes through them outermost to innermost:

request_id -> TraceLayer -> record_metrics -> CORS -> shed_load -> enforce_header_limit -> TimeoutLayer -> handler

A handler then does, in order:

  1. Ask the registry for the asset by ID: a cache hit is two short mutex sections; a miss resolves the location, opens the source, and loads it (404, 502, 503, or 500 on failure).
  2. For init and media routes, require ?v= to equal the asset version (404 otherwise).
  3. Check If-None-Match and answer 304 if it matches.
  4. Produce the body:
    • Playlist or manifest: clone precomputed Bytes.
    • Init segment: slice the cached init Bytes, honoring Range.
    • Media segment: build the header on the blocking pool, then stream header plus source ranges from an async task.

The request span and metrics record the outcome. See HTTP server for the details of each step.

Core data model

Everything downstream of parsing works on three immutable values, built once per asset.

TypeDefined inMeaning
MediaIndexmedia/index.rsSource identity, movie timescale and duration, and a Track per audio or video track
Trackmedia/index.rsTrack ID, kind, timescale, codec configuration, and a Vec<Sample>
Samplemedia/index.rsOne encoded frame or audio packet: byte offset and size in the source, decode_time, duration, composition_offset, and is_sync
SegmentPlansegment/planner.rsA list of Segments, each holding one TrackSegment per track: a half-open sample range plus decode time and duration
PackagedAssetasset.rsSource, index, plan, cached init segments, version, and pre-rendered playlists

A Sample holds where the data is, never the data. Nothing in the index contains media payload bytes.

Timestamps are integers in each track’s own timescale (ticks per second). Conversion between timescales happens only at segment boundaries, with checked integer arithmetic (segment::planner::rescale).

Concurrency model

WorkRuns onBounded by
HTTP accept, routing, middleware, playlistsTokio worker threadsmax_concurrent_requests (soft)
Asset metadata fetch (local or remote)Async taskslimits.max_startup_parses load slots, remote max_inflight_reads
Asset assembly (planning, init segments, rendering)Tokio blocking poolThe same load slots
Segment header constructionTokio blocking pool (spawn_blocking)Blocking pool size
Source reads for segmentsLocal: blocking pool, one read per chunk; remote: async ranged HTTPlimits.max_segment_jobs slots, held per read
Streaming a segment responseOne async task per responseTwo-item channel, response_idle_timeout_ms
Log writingOne dedicated thread (tracing-appender)logging.buffer_capacity, lossy
Dropped-log monitorOne dedicated threadWakes every 10 s

Loaded assets are shared as Arc<PackagedAsset> and are immutable, so no request path takes a lock on media data. The only shared mutable state is semaphores, atomics (metrics, readiness), and the log queue.

Conventions

  • Checked arithmetic on anything derived from a file. Offsets, sizes, counts, and timestamps come from untrusted input. Use checked_*, try_from, and explicit limits. Limits are checked before allocation or table expansion.
  • Typed errors, generic responses. Return Error variants; HttpError decides the status. Error text goes to logs, never to clients (except the short messages for 404).
  • No payload in the index, no payload in logs.
  • Comments say why. Doc comments are used for invariants and non-obvious decisions, not to restate code.
  • Lints. unsafe_code is forbidden, Clippy all and pedantic warn and CI denies warnings, missing_docs warns, and rustfmt uses max_width = 100. Run make ci before pushing.
  • Keep config/ lean. It stores plain strings for CORS and leaves parsing into HTTP types to http/cors.rs. This was required while the fuzz target compiled it by path; it is now only a preference.

Where to start for common changes

I want toStart in
Support another codecmp4/parser.rs (parse_codec, validate_sample_description), media/index.rs (CodecConfig), protocol/hls.rs and protocol/dash.rs (codec strings), fmp4/init.rs
Change how segments are cutsegment/planner.rs
Change fragment layoutfmp4/fragment.rs (build_moof, prepare_media_segment)
Add or change an HTTP routehttp/router.rs (add a constant and list it in ROUTES, which also labels metrics) and a file in http/handlers/
Add a config optionconfig/mod.rs (raw struct, validation, Config) or the matching file in config/, vod.example.toml, and the docs
Add a limitLimitsConfig in config/limits.rs, its validation and default, then enforce it where the resource is allocated
Add a metricobservability/metrics.rs (field, recorder, render)
Add a source of media bytessource/ (see the mapper design)

Media pipeline

These modules turn an MP4 file into segment data. They contain no HTTP and no protocol text, and they can be tested with nothing but a fixture file.

flowchart LR
    File[(MP4 file or remote object)] --> Source[source::MediaSourceKind]
    Source --> Sparse[source::Metadata<br/>the moov box]
    Sparse --> Parser[mp4::parse]
    Parser --> Index[media::MediaIndex]
    Index --> Planner[segment::plan]
    Planner --> Plan[SegmentPlan]
    Index --> Frag[fmp4::prepare_media_segment]
    Plan --> Frag
    Sparse --> Init[fmp4::write_init_segment]
    Frag --> Seg[header + byte ranges]

How an MP4 is organised (just enough to read the code)

An MP4 is a sequence of boxes, each with a 4-byte size and a 4-byte type name. Two matter here:

  • mdat holds the encoded frames back to back.
  • moov holds metadata: for each track (trak), a sample table (stbl) describing every frame.

The sample table is stored compactly, as separate run-length or chunked tables that the parser expands:

BoxTells usExpanded into
stszSize of each sample (or one constant size)Sample.size
stsc + stco/co64Which samples are in which chunk, and where each chunk startsSample.offset
sttsDecode-time deltas, run-length encodedSample.decode_time, Sample.duration
cttsComposition (display) offsets for B-framesSample.composition_offset
stssWhich video samples are keyframesSample.is_sync (absent means every sample is sync)
stsdCodec configuration (H.264 SPS/PPS, AAC parameters)Track.codec

moov may come before or after mdat; both layouts are supported (h264-aac-moov-last.mp4 is a fixture).

source/ : reading bytes

source/mod.rs defines what the rest of the pipeline reads through.

  • ByteRange { offset, length } with a checked end().
  • SourceIdentity: an Origin, the length, and an optional moov_sha256. Origin::Local records canonical path, device, inode, and modification time; Origin::Remote records the URL without its query string and the validator reads are conditioned on. It identifies exactly which bytes were parsed.
  • MediaSourceKind: Local or Http, with async read_range and verify_unchanged.

local.rs (LocalMediaSource) is the Linux file source.

  • open canonicalizes the path, opens the file, and records device, inode, length, and mtime.
  • read_range checks that offset + length does not overflow and stays within the file, then allocates and fills the buffer with read_exact_at. Positioned reads (pread) share no cursor, so concurrent requests can read one File without locking. It is synchronous; MediaSourceKind::read_range runs it on the blocking pool, one chunk per call.
  • verify_unchanged re-reads metadata and fails if device, inode, length, or mtime changed since open.

http.rs is the remote source and sparse.rs the metadata reader; both are described in Registry and resolvers.

Contributing: keep reads bounds-checked before allocating, and never share a seek cursor on the request path.

media/ : the immutable index

media/index.rs contains only data types (MediaIndex, Track, TrackKind, CodecConfig, Sample). CodecConfig::Avc carries width, height, the profile, compatibility and level bytes, and the first SPS and PPS. CodecConfig::Aac carries sample rate and channel count. Sample is Copy and about 40 bytes with padding, which is what PackagedAsset::index_bytes uses to estimate memory.

mp4/parser.rs : bytes to MediaIndex

Entry point: async parse(&MediaSourceKind, limits) -> ParsedMedia { index, metadata }, where metadata is the Metadata the index was built from (the init segment writer reuses it, so the file is not read again). The parser is in-tree, built on a bounded box walker (mp4/boxes.rs) whose every length is checked against the bytes present. It is deliberately defensive, in this order:

  1. Size limit. Reject sources larger than limits.max_source_bytes.
  2. Metadata::fetch (async). Walk top-level boxes through an 8 KiB read window, so consecutive small reads near each other cost one request (an HD fragment’s whole moof and the header of its mdat usually fit). Keep moov whole, and keep every moof whole with its offset. A sidx seen before the first fragment is read as a hint about where the fragments are: when the walk arrives exactly at the start of the region it describes, the subsegments are fetched limits.metadata_concurrency at a time, each verified by walking its own boxes to an exact end, and any mismatch (or a sidx that is hierarchical, has zero sizes, or lists more than max_fragments) leaves the walk sequential. A file that ends inside a fragment is refused with a message naming limits.tolerate_truncated_tail, which, when set, drops the cut moof or mdat (with the moof a cut mdat belongs to) if at least one whole fragment precedes it. Reject impossible sizes, a missing moov, more than 4,096 other top-level boxes, more than limits.max_fragments fragments or mdat boxes, and moov plus moof bytes beyond limits.max_metadata_bytes. Nothing in mdat is ever read, whether the source is local or remote. Because every top-level box header is visited, a truncated mdat is rejected here, before any table is expanded.

The remaining steps are synchronous CPU work in parse_metadata, run on the blocking pool:

  1. validate_raw_moov. Walks moov once and decides, per track, whether it is packaged:
    • mvex in moov means fragmented input: its trex defaults are read, and the samples come from the moof boxes instead of from moov;
    • tracks whose handler is neither vide nor soun (timecode, timed metadata, subtitles) are skipped, and their tables are never read;
    • dinf/dref must contain exactly one self-contained url entry (no external data references);
    • stsd must contain exactly one entry, and it must be avc1 or mp4a; encv/enca (encrypted) are rejected, an unknown video entry with at most one sample is skipped as a still image, and anything else is rejected naming the entry.
  2. Hash moov with SHA-256.
  3. parse_track per packaged track: mdhd (timescale, duration, language), then the codec configuration from the sample entry (mp4/codec.rs). Only what the manifest needs is read: avcC, hvcC, vpcC, or av1C for the codec string and dimensions; esds for AAC, whose audio object type must be LC, SBR, or SBR with parametric stereo (QuickTime’s versioned entries with a wave box included); dac3, dec3, dOps, or dfLa for AC-3, E-AC-3, Opus, and FLAC. Then the sample tables.
  4. Sample tables (mp4/tables.rs). stts, ctts (signed in version 1), stss, stsc, stsz or the compact stz2, and stco or co64 are parsed with every entry count checked against its box before allocating. expand_samples then checks the sample count against limits.max_samples_per_track and expands the tables through sample_sizes, sample_offsets, sample_times, and composition_offsets. sample_times and composition_offsets bound each run-length entry against the sample count before expanding it, so a crafted stts claiming billions of samples fails immediately. Every sample’s byte range must end inside the source. Fragmented files (mp4/fragments.rs) take their samples from the moof boxes instead. Each traf’s tfhd gives the track, the base offset (explicit, relative to the moof, or, in the legacy layout, where the previous traf’s data ended), and defaults; tfdt gives the fragment’s decode time, continuing from the previous fragment when absent; each trun lists samples whose fields fall back to the tfhd and then the trex defaults. A trun cannot claim more entries than its bytes hold, a run belonging to another track is measured by arithmetic and never iterated, and a fragment that starts before the previous one ended is rejected because the planner needs samples in decode order. A track’s samples in moov as well as in fragments are refused.
  5. Edit lists (mp4/edit.rs). Each track’s edit list is read: one edit, optionally after one empty edit, is applied by shifting every track forward by one shared offset so no decode time goes negative (see TDD 0004); other shapes are rejected. Skipped tracks are reported in MediaIndex::skipped_tracks. For a fragmented file the timeline is then moved to start at zero, by the earliest first decode time across tracks found in seconds, and each track’s duration is taken from its last sample, because the durations in mvhd, mdhd, and tkhd are usually zero. Tracks are numbered in file order: one video track, then audio-1, audio-2, and so on.

Back in async code:

  1. Mutation check. verify_unchanged on the source, then re-read and re-hash moov; if either differs, the source changed mid-parse and the result is discarded. For a remote object verify_unchanged re-probes length and validator.

The result carries the moov hash in its SourceIdentity. PackagedAsset::version is derived from it.

Contributing: the tests in the module compare every sample against tests/fixtures/h264-aac.ffprobe.json, an FFprobe dump committed as ground truth. When adding a rejection rule, mutate the fixture’s moov bytes in a test (see find_type and fixture_moov in the tests, which run the async fetch on a private runtime) instead of committing another binary.

segment/planner.rs : where to cut

plan(index, target_duration_ms, limits) -> SegmentPlan.

Rules: at most one video track, at least one track of some kind, and the first sample of the reference track must be a sync sample. The reference track is the video track, or with no video the first audio track.

  • Reference boundaries (reference_boundaries): start at sample 0. From each boundary, the next boundary is the first sync sample whose decode time is at least boundary_time + target. The target duration is a goal, not a guarantee; segments are exactly as long as keyframe spacing allows, and the last one takes whatever remains.
  • Audio follows the reference (audio_segment): convert the reference segment’s start and end times to the audio timescale (rescale) and take every audio sample whose decode time falls in that window using binary search (partition_point). The final segment takes all remaining audio.
  • Durations are real. A segment’s duration is computed from actual sample decode times, so playlists report true values.
  • Each segment is checked against limits.max_samples_per_segment.

Output: Segment { index, tracks: Vec<TrackSegment> }, where a TrackSegment is first_sample..end_sample plus decode_time and duration in that track’s timescale.

Contributing: all timescale conversions must use rescale (checked). Any change here changes segment URLs’ contents, so run the FFmpeg decode tests.

fmp4/ : building fragments

Two independent writers, both producing ISO BMFF that players can consume.

fmp4/init.rs: initialization segment

write_init_segment(metadata, track_id) builds ftyp + moov for a single track from the moov bytes kept by the parse, at the byte level, without re-serializing through a box model.

  • ftyp is fixed: major brand iso6, compatible iso6 and mp41. The source’s brands describe the source file, not this stream.
  • The stsd box (the sample entry) is copied byte for byte. That is what keeps pasp (pixel aspect ratio), colr, HDR boxes, and the codec configuration exactly as the encoder wrote them, and it is why a new codec needs no per-codec box writer. The one exception is a QuickTime-style mp4a entry (sound description version 1, esds inside wave), which browsers refuse; it is rewritten in the ISO layout with the same channel count, sample rate, and esds.
  • mvhd, tkhd, and mdhd are copied with their durations zeroed; hdlr, vmhd/smhd, and dinf are copied as they were.
  • The sample tables (stts, stsc, stsz, stco) are written empty, and a hand-built mvex/trex tells players the file is fragmented.
  • Everything else is left out: the edit list (already applied to the sample timestamps, so a player must not apply it again), udta, and other tracks.

Each track gets its own init segment (“separate tracks”; see ADR 0001). Init segments are built once at asset load and cached.

fmp4/fragment.rs: media segment

prepare_media_segment(track, track_segment, sequence_number, limits) -> PreparedSegment does not copy payload. It returns:

  • header: a moof box followed by an 8-byte mdat header,
  • ranges: the source byte ranges to append (adjacent samples are merged by coalesced_ranges, so a segment is usually a handful of large reads),
  • content_length: header length plus payload length.

The moof is built by build_moof with mfhd (sequence number), traf, tfhd (default-base-is-moof), tfdt (64-bit base decode time), and a trun listing every sample’s duration, size, flags, and composition offset. The trun carries the offset from the start of the moof to the first payload byte, which depends on the moof’s own size, so the function builds it twice: once with a placeholder to measure, then again with the real offset.

Sample flags mark keyframes (SYNC_SAMPLE_FLAGS) versus dependent frames (NON_SYNC_SAMPLE_FLAGS); audio samples are always sync. Payload total is checked against limits.max_segment_bytes. Boxes larger than 4 GiB are not supported (32-bit sizes).

write_media_segment is async and assembles a complete in-memory segment (header plus ranges read through the source). Only the package command uses it; the server streams instead.

Contributing: never read payload inside prepare_media_segment; the HTTP path depends on it being metadata-only so HEAD and range requests stay cheap. Changing box layout should be validated by the FFmpeg decode tests and tests/package.rs (determinism).

Protocols and assets

asset.rs and the protocol/ module sit between the media pipeline and the HTTP layer. asset owns the loaded state of one media file; protocol turns a read-only view of it into playlist text.

asset.rs : PackagedAsset

A PackagedAsset is everything the server knows about one media file, built once by PackagedAsset::load(source, segment_duration_ms, limits) (async; load_local(path, ..) opens a file first, for the CLI, tests, and benchmarks):

FieldMeaning
sourceThe open MediaSourceKind (local file or remote object), used for payload reads
indexThe MediaIndex from mp4::parse
planThe SegmentPlan from segment::plan
init_segmentsOne cached init segment (Bytes) per TrackKind
limitsA copy of the limits, needed when preparing segments
versionFirst 8 bytes of the moov SHA-256, as 16 hex characters
renderedPre-rendered HLS master, HLS media playlists per track, and the DASH manifest

load first awaits mp4::parse (async metadata fetch), then runs the CPU-bound rest, assemble, on the blocking pool: plan, build init segments from the parse’s metadata, compute version, then render. Rendering takes a Presentation built from the index, plan, and version, not the asset, so the asset is constructed once with its rendered text already in place.

Main methods:

  • track(kind) (delegates to Presentation::track), init_segment(kind): look up a track or its init segment; a missing track is Error::NotFound, which the HTTP layer turns into 404.
  • prepare_media_segment(kind, segment_index): find the track’s TrackSegment in the plan and call fmp4::prepare_media_segment with sequence number segment_index + 1. An out-of-range index is Error::NotFound. It is CPU-only metadata work.
  • read_range(range): an async payload read from the source; the streaming task calls it once per chunk.
  • hls_master_playlist(), hls_media_playlist(kind), dash_manifest(): clone the pre-rendered Bytes.
  • presentation(): returns the Presentation view over this asset.
  • index_bytes(): estimated resident memory (samples, init segments, rendered text), used for the startup memory budget.

The version doubles as the cache key in URLs (?v=) and ETags. It changes whenever the moov box changes, and only then.

Contributing: anything computed from the whole sample table belongs in load, not in a request handler. Anything you add to PackagedAsset that holds memory should be counted in index_bytes.

protocol/presentation.rs : the renderer input

Presentation<'a> is a Copy view of three borrowed things: the tracks, the SegmentPlan, and the version string. It is all a renderer needs and it keeps protocol independent of asset. Methods:

  • track(kind): find a track, or Error::NotFound.
  • tracks() and version().
  • track_segments(track_id): one track’s segments in playback order.
  • bandwidth(track): average and peak bits per second. Average is total payload bytes over track duration; peak is the largest per-segment rate, so it reflects the burstiest segment.

Unit tests build a Presentation straight from the fixture (protocol::fixtures::Loaded) without loading a full asset.

protocol/hls.rs : HLS playlists

Two pure functions from a Presentation to String, both called once at load.

master_playlist(presentation) writes an #EXTM3U master with #EXT-X-VERSION:7:

  • CODECS joins the video codec string with the default audio track’s, each from CodecConfig::codecs() (RFC 6381: avc1. plus three hex bytes, hvc1. per ISO/IEC 14496-15, vp09., av01., mp4a.40.{object type}, ac-3, ec-3, opus, fLaC).
  • Every audio track is declared as an #EXT-X-MEDIA:TYPE=AUDIO rendition in group audio, with URI audio-{n}/index.m3u8?v={version}. The first is DEFAULT=YES. NAME is Audio {n}, followed by the language in parentheses when the file names one, and LANGUAGE is set from mdhd. mp4a.40.2 is appended to CODECS, and the variant’s bandwidth counts the default rendition only.
  • One #EXT-X-STREAM-INF carries BANDWIDTH (video peak plus audio peak), AVERAGE-BANDWIDTH (sums of averages), CODECS, RESOLUTION, and the AUDIO group, followed by video/index.m3u8?v={version}.
  • An audio-only asset has no video, so the variant has no RESOLUTION and points at the first audio track’s playlist. A lone audio track is just that variant, with no #EXT-X-MEDIA; several audio tracks are renditions of it, with the variant naming their group.

media_playlist(presentation, kind) writes a VOD media playlist for one track: #EXT-X-TARGETDURATION is the largest segment duration rounded up to whole seconds; #EXT-X-PLAYLIST-TYPE:VOD, #EXT-X-INDEPENDENT-SEGMENTS, and #EXT-X-MAP:URI="init.mp4?v=..." reference the init segment; each segment gets #EXTINF with millisecond precision and the URI segments/{n}/media.m4s?v=...; the file ends with #EXT-X-ENDLIST.

All URLs are relative, so they resolve beneath the route the playlist was fetched from. No filesystem path ever appears in output.

protocol/dash.rs : the DASH manifest

manifest(presentation) writes a static MPD (type="static", profile isoff-main:2011):

  • mediaPresentationDuration is the longest track duration in seconds.
  • The video track becomes one AdaptationSet with Representation id="video"; each audio track becomes another, with id="audio-{n}", a lang when known, an audioSamplingRate, and an AudioChannelConfiguration.
  • Each representation has a SegmentTemplate in that track’s timescale, startNumber="0", initialization="$RepresentationID$/init.mp4?v=...", media="$RepresentationID$/segments/$Number$/media.m4s?v=...", and a SegmentTimeline with one <S d="..."/> per segment. The first entry also carries t, because a file with an edit list starts after zero. A timeline is used because real segment lengths vary with keyframe placement.
  • Representation@bandwidth is the peak bitrate.

Because the representation IDs are the track names video and audio-{n}, $RepresentationID$/... resolves to the same /dash/{asset}/{track}/... routes the server registers.

Contributing: the two renderers share the same fragments, so a fragment change affects both. Renderer unit tests check structure; FFmpeg decode tests in http/tests.rs check that players can actually play the output. Anything interpolated into text must be escaped or provably safe; today only numbers and hex strings are interpolated.

Registry and resolvers

How an asset ID becomes a loaded, servable PackagedAsset. Three modules cooperate: resolver says where the media is, source reads it, and registry caches the answers and schedules the work. The design is in TDD 0002; the wire contract mapper authors implement is in the Mapper API reference.

flowchart LR
    H[HTTP handler] -->|state.asset id| R[AssetRegistry]
    R --> RC[Resolution cache]
    R --> LC[Loaded cache]
    R -->|resolve| RES{AssetResolver}
    RES --> ST[StaticResolver<br/>config file]
    RES --> HR[HttpResolver<br/>mapper client]
    HR --> M[(Mapper service)]
    R -->|open location| OP[SourceOpener]
    OP --> LS[LocalMediaSource]
    OP --> RS[HttpMediaSource]
    RS --> O[(Media origin)]
    OP --> PA[PackagedAsset::load]

resolver/ : where is the media?

resolver/mod.rs defines the vocabulary.

TypeMeaning
AssetLocationFile(PathBuf) (beneath storage.media_root) or Http(Url)
ResolvedAssetLocation, a required version, valid_until (when to revalidate), and optional hard_expiry (when the location itself dies)
ResolutionResolved(ResolvedAsset) or Unchanged { valid_until } (a mapper 304)
ResolveErrorNotFound, Unavailable (retry later), Rejected (the answer is invalid or forbidden)
AssetResolverAn enum, Static or Http, with an async resolve(id, known_version). An enum rather than a trait object because native async traits are not dyn-compatible and this avoids a dependency

catalog.rs answers from the [assets.*] tables. Every answer has the constant version "static" and effectively never expires. It also lists its IDs so the registry can preload them.

mapper.rs is the mapper client. For each lookup it sends GET {base_url}/v1/assets/{id} with Accept, an optional bearer token, X-Request-Id, and If-None-Match when it holds a usable answer. It maps statuses to errors, retries Unavailable failures with a short backoff (honoring a capped Retry-After), reads the body with a hard size cap, then validates everything in interpret: the echoed ID, the version’s shape, the location (relative-path rules, or URL policy), the TTL (explicit, else Cache-Control, else default, clamped), and expires_at. Unknown JSON fields are ignored; an unknown location type fails deserialization and is rejected. Secrets stay in Secret, whose Debug output is redacted.

policy.rs holds LocationPolicy (scheme, credentials, host allow-list, literal-IP check) and validate_relative_path. Both are pure and heavily unit-tested because they are the trust boundary.

source/ : reading the media

MediaSourceKind is Local(Arc<LocalMediaSource>) or Http(Arc<HttpMediaSource>), with async read_range and verify_unchanged. Local reads run on the blocking pool, one chunk at a time; remote reads are ranged HTTP requests.

http.rs contains RemoteReader (one pooled reqwest client shared by all remote sources, an in-flight semaphore, retry settings) and HttpMediaSource.

  • Open sends Range: bytes=0-0 and requires 206, a Content-Range giving the total length, and a validator: a strong ETag, else Last-Modified. A 200 (no range support), no validator, or only a weak ETag is refused.
  • Read sends the range with If-Range: <validator>. It requires 206, an exact matching Content-Range and total length, and, for an ETag, the same tag on the response. A 200 or 412 means the object changed and fails the read as invalid media instead of returning mixed bytes. Transient failures (timeouts, connection errors, 5xx, 429) are retried up to max_retries and surface as UpstreamUnavailable; everything else is Upstream.
  • Bounded body. The response body is accumulated only up to the requested length; more is an error.
  • Address filtering. The client resolves names through FilteringResolver, which drops loopback, private, link-local, shared, multicast, unspecified, and unique-local addresses (including IPv4-mapped forms) unless allow_private_addresses is set. Filtering at connect time defeats DNS rebinding. Literal IPs skip resolution, so LocationPolicy checks them separately. Redirects are disabled.
  • Verify re-probes the object and compares length and validator.

metadata.rs is what makes metadata parsing independent of where a file lives. Metadata::fetch walks the top-level box headers with small reads and fetches moov whole; nothing else is read, because every sample’s location and timing is in moov and the payload is only read later, one segment at a time. A remote parse therefore costs a few small range requests, not a download. It caps the top-level box count so a hostile file cannot cause millions of round trips.

mp4::parse (in Media pipeline) runs fetch, parses on the blocking pool, then re-verifies the source and re-hashes moov to detect a change during parsing.

registry/ : caches and scheduling

AssetRegistry::get(asset_id) is the only call the HTTP layer makes per request.

Fast path

  1. Reject syntactically invalid IDs (they never reach a resolver).
  2. lookup takes two short std::sync::Mutex sections and no I/O: a cached recent failure, a negative entry, or a fresh resolution whose asset is in the loaded cache returns immediately.

Slow path (single flight)

  1. enter_flight takes a per-asset tokio mutex, creating it on demand. A request that finds it busy is a coalesced waiter (counted in metrics). The Flight guard removes the map entry on drop when nobody else holds it, so the map cannot grow without bound.
  2. After the lock the request looks up again; another request may have finished the work.
  3. Otherwise it spawns a task that owns the guard and runs resolve_and_load. The work therefore continues if the requesting client disconnects, and the waiters get its result from the cache.

ensure_resolved:

  • A fresh resolution is used as is. A stale one is revalidated with its version as If-None-Match (only if its location has not hit hard_expiry).
  • Resolved replaces the entry. If the version or the object (AssetLocation::same_object: scheme, host, port, and path) changed, every loaded copy of the asset is dropped first, with no grace period. If only the URL’s query changed (a re-signed URL), the loaded asset is kept and PackagedAsset::update_location points its remote source at the new URL (in-place rotation).
  • Unchanged extends valid_until, never beyond hard_expiry.
  • NotFound records a negative entry and evicts loaded copies.
  • Unavailable serves the previous answer if it is still inside stale_if_error and its location is not hard-expired, and sets a short backoff so the mapper is not asked again for every request. Otherwise the failure is cached for error_ttl and returned as 503.
  • Rejected becomes BadUpstream (502) and is cached briefly.

load opens the location through SourceOpener, acquires a slot from the load semaphore (max_startup_parses, waiting at most load_queue_timeout, else 503), runs PackagedAsset::load, and inserts the result. A failed load is cached briefly so a broken file is not reparsed on every request. SourceOpener confines file locations: join to the media root, canonicalize (resolving symlinks), require a regular file, and require the result to still be under the root.

Signed URLs

Three mechanisms keep a remote asset readable as its signed URL changes:

  1. Refresh ahead (mapper.rs::interpret): for an answer with expires_at, valid_until is set to max(remaining - refresh_margin, remaining / 2) from now, so the next request after that point revalidates while the old URL still works.
  2. In-place rotation (store_resolution): described above. HttpMediaSource keeps its URL in a mutex, so set_url takes effect on the next fetch.
  3. Recovery on rejection: HttpMediaSource::read_range maps origin 401/403/410 to Error::LocationRejected. If the source has a LocationRefresher, it calls it once (refreshed guards against loops), swaps the URL, and retries. The registry supplies AssetRefresher, which holds a Weak<AssetRegistry> (no reference cycle) and calls AssetRegistry::refresh_location. That takes the asset’s single-flight lock, reuses a location fetched within the last two seconds (so a burst of rejections causes one mapper call), and otherwise asks the mapper unconditionally. The refresher is disarmed while the asset is loading, because the loading task already holds the flight lock and waiting on it would deadlock.

Caches

  • Resolution cache: id -> Slot, either Found { resolved, backoff_until } or Missing { until }, bounded by max_cached_resolutions (expired entries are swept first).
  • Loaded cache (cache.rs): a byte-weighted LRU keyed by (asset_id, version). Weight is PackagedAsset::index_bytes; inserting evicts the least recently used entries above the budget (limits.max_index_bytes). An asset heavier than the whole budget is served but not retained. Only one version of an asset is ever resident. Requests already holding an Arc are unaffected by eviction.

Errors

RegistryError maps to HTTP in one place (http/error.rs): NotFound to 404, Unavailable to 503, BadUpstream to 502, LoadFailed (unsupported or invalid media) to 500.

Preload

preload loads every ID the resolver already knows (the static catalog) concurrently, logs each, and fails startup with asset \x` could not be loaded: …if any fails. It also fails if their combined index size exceedslimits.max_index_bytes`. With a mapper there is nothing to preload.

Configuration touched

[resolver], [resolver.http], [registry], and [remote_media] in config/resolver.rs; see vod.example.toml and Operating the origin.

Contributing

  • Keep the resolver and registry free of blocking calls; blocking work goes through spawn_blocking.
  • Every value that comes from a mapper is untrusted. Add checks to interpret or policy.rs, with a test in registry/tests.rs that feeds the bad answer through the mock mapper and asserts 502.
  • New failure modes need a RegistryError mapping and a decision about whether to cache the failure.
  • Tests use real in-process servers (testutil.rs): MockMapper (scriptable status, delay, body, token, health) and MockOrigin (range and validator behavior, request and byte counters). Prefer extending them to faking a trait.

HTTP server

The src/http/ module (plus observability/metrics.rs), built on Axum 0.8, Tokio, and Tower.

FileContents
mod.rsserve: bind, wire shutdown, grace timer
server.rsThe accept loop: connection cap, header-read timeout, graceful drain
state.rsAppState::new (resolver, registry, clients, CORS), preload, job-slot acquisition
router.rsRoute table and layer order
middleware.rsrequest_id, record_metrics, enforce_header_limit, shed_load
handlers/health.rs (health, ready, metrics), playlist.rs, media.rs; parse_track in mod.rs
stream.rsStreamJob, the streaming task
range.rsByteInterval, Range and If-Range parsing, 416
validators.rsEntity tags and If-None-Match
cors.rscors_layer
error.rsHttpError
shutdown.rsshutdown_signal
tests.rsRouter-level tests

The sections below follow the request path.

AppState

Cheaply cloneable shared state passed to every handler and middleware:

FieldPurpose
registryArc<AssetRegistry>: state.asset(id).await resolves and loads on demand (see Registry and resolvers)
segment_jobsSemaphore of limits.max_segment_jobs source-read slots
request_slotsSemaphore of limits.max_concurrent_requests handler slots
metricsArc<Metrics>
readyReadiness flag, cleared when shutdown starts
corsOptional prebuilt CORS layer
timeouts and sizesQueue timeout, idle timeout, request timeout, chunk size, header limit

AppState::new(config) (in state.rs) is synchronous and loads nothing: it builds the CORS layer, the remote-media client and SourceOpener, the chosen resolver, and the registry. preload() then loads the static catalog before the listener binds, so a bad asset stops startup; with a mapper it does nothing. asset(id) is async and delegates to the registry; RegistryError becomes 404, 503, 502, or 500 in http/error.rs.

serve and shutdown

serve(config) loads state, binds the listener, logs service_ready, and runs server::serve_connections with the connection limits and a shutdown future.

server.rs: the accept loop

serve_connections replaces axum::serve because that offers no header-read timeout or connection cap. For each accepted socket it takes a permit from a semaphore of limits.max_connections (closing the socket and counting a rejection if none is free), enables TCP_NODELAY, and spawns a task that runs hyper’s auto (HTTP/1 and HTTP/2) connection with the router as its service. The permit and the open-connection gauge guard live for the task. hyper’s HTTP/1 header_read_timeout (limits.header_read_timeout_ms, with a TokioTimer) closes connections that are slow to send headers or idle between requests. Accept errors that name one connection are skipped; others are logged with a one-second back-off. On shutdown the loop stops accepting and awaits GracefulShutdown, which closes idle connections and waits for active ones.

The shutdown future passed to serve_connections waits for shutdown_signal() (SIGINT, or SIGTERM on Unix), clears ready so /ready returns 503, and sleeps shutdown_delay_ms before the accept loop stops. A tokio::select! races the server against a timer that starts when shutdown begins and fires after shutdown_delay_ms + shutdown_grace_ms, so a stuck stream cannot block exit forever.

router and middleware

router(state) registers routes, then adds layers. In Axum the last .layer() call is the outermost, so the order in code is the reverse of the request path.

Layer (outermost first)What it does
request_idKeeps a valid incoming X-Request-Id or generates one, and echoes it on the response
TraceLayerCreates an info-level request span carrying the request ID; logs request and response at debug
record_metricsRecords route, status, and time to headers; the in-flight gauge is a guard that survives cancellation
CORS (optional)Adds Access-Control-* headers; sits outside the limit layers so their errors carry them
shed_loadtry_acquire a request slot or return 503 with Retry-After; /health, /ready, /metrics bypass it
enforce_header_limitSums header name and value bytes; over max_request_header_bytes returns 431
TimeoutLayerReturns 408 if a handler takes longer than request_timeout_ms to produce headers

The timeout does not cover body streaming; response_idle_timeout_ms does (below).

Routes:

GET|HEAD /health  /ready  /metrics
/hls/{asset_id}/master.m3u8
/hls/{asset_id}/{track}/index.m3u8
/hls/{asset_id}/{track}/init.mp4
/hls/{asset_id}/{track}/segments/{segment_index}/media.m4s
/dash/{asset_id}/manifest.mpd
/dash/{asset_id}/{track}/init.mp4
/dash/{asset_id}/{track}/segments/{segment_index}/media.m4s

HLS and DASH share the init_segment and media_segment handlers.

Handlers

Playlists and manifests

master_playlist, media_playlist, and dash_manifest look up the asset, build the ETag, return 304 if If-None-Match matches, and otherwise return the pre-rendered Bytes with max-age=60. They do no per-request computation.

init_segment

Requires the matching ?v= (VersionQuery::require), then checks If-None-Match, then serves the cached init Bytes, honoring Range, with immutable caching and the track-specific content type (video/mp4 or audio/mp4).

media_segment

The most involved handler:

  1. Look up asset, require ?v=, parse the track, and answer 304 if the ETag matches.
  2. Prepare the segment on the blocking pool (prepare_media_segment): this yields the header bytes, source ranges, and total length without reading payload.
  3. Compute the requested byte interval from Range and If-Range; 416 if unsatisfiable.
  4. If the method is HEAD, return headers with an empty body. No job slot, no task.
  5. Otherwise acquire the first job slot (503 on queue timeout), spawn a StreamJob, and return Body::from_stream over a two-item channel.

The response’s Content-Length and Content-Range are known up front because the header length and payload length are known from metadata.

StreamJob

An async task that produces the body. Its stream method:

  1. Sends the part of the header (moof plus mdat header) that overlaps the requested interval.
  2. Walks the prepared source ranges. Byte positions in the response are “virtual offsets” (header first, then each range in order); for each range it intersects the requested interval and reads that overlap in chunks of stream_chunk_bytes.
  3. For each chunk: read acquires a job slot (the first read reuses the slot acquired by the handler), runs PackagedAsset::read_range on the blocking pool, releases the slot, then send pushes the chunk into the channel under response_idle_timeout_ms.

Failure handling: a source-read or permit failure sends one generic io::Error into the body (so the connection aborts instead of ending cleanly short) and counts an error abort. A full channel for longer than the idle timeout drops the stream and counts idle. A closed channel means the client left and counts client.

Invariants worth preserving: a slot is never held while waiting on the client; every send is time-bounded; nothing here reads more than the requested interval.

Conditional and range helpers

  • entity_tag(asset, resource) builds a strong ETag "<version>-<resource>".
  • not_modified implements If-None-Match (tag lists, weak tags, *).
  • requested_range(headers, total, etag) parses one bytes= range, including suffix bytes=-n. If-Range that differs from the ETag means “ignore the range”. Multi-range and malformed ranges are Err(()), which handlers turn into 416 via range_not_satisfiable.
  • ByteInterval { start, end } is half-open, with overlap used by StreamJob.
  • media_response_builder sets 200 or 206, content type, immutable cache control, Accept-Ranges, Content-Length, Content-Range when partial, and the ETag.

Errors

HttpError { status, message } with constructors not_found, internal, unavailable. From<Error> maps Error::NotFound to 404 and everything else to 500. IntoResponse:

  • picks a generic public message for server errors (detail goes only to logs),
  • logs 503 at warn (expected under load) and other 5xx at error, 4xx at warn,
  • always adds Cache-Control: no-store, plus Retry-After: 1 on 503.

CORS

cors_layer(&CorsConfig) converts the validated strings into tower-http types (AllowOrigin, AllowMethods, AllowHeaders, ExposeHeaders), applies max_age and allow_credentials, and returns None when disabled. Parsing failures return Error::Configuration, so a bad value stops startup.

Metrics

observability/metrics.rs: a Metrics struct of atomics: a requests[route][status] matrix, per-route histogram buckets and duration sums, and scalar counters (bytes, shed, queue timeouts, three abort reasons, an in-flight gauge). Metrics::new(&ROUTES) takes the route templates from http/router.rs, which defines each template once and uses the same constant to register the handler; two extra slots cover unmatched (no route matched) and other. STATUSES lists the tracked codes and anything else folds into other, so label cardinality is fixed. Metrics::route_index maps Axum’s MatchedPath to an index. render writes Prometheus text.

Contributing (adding a route): add it to router, add a template constant and list it in ROUTES in http/router.rs (metrics pick it up automatically; bump the array length), decide whether it should bypass shed_load, and add a test that drives it with oneshot. Handlers should return HttpResult<Response> and never format internal detail into a response.

Runtime support

The modules that everything else depends on: configuration, errors, logging, metrics, and the command-line entry point.

config/ : configuration

Files: mod.rs (Config, parsing, asset resolution, server, storage, packaging, and asset sections, tests), limits.rs (LimitsConfig), cors.rs (CorsConfig), logging.rs (LoggingConfig, LogLevel, LogFormat), resolver.rs (resolver choice, mapper client, registry, and remote-media settings, plus the redacting Secret).

Config::load(path) reads a TOML file and calls Config::parse(contents, config_directory), which deserializes into the private RawConfig, validates, and returns the public Config. The split exists because the file format (relative paths, optional sections) differs from what the rest of the program wants (canonical absolute paths, defaults applied).

Every section uses #[serde(deny_unknown_fields)], so a misspelled key is an error rather than silently ignored.

SectionTypeNotes
[server]ServerConfiglisten, shutdown_delay_ms (default 0), shutdown_grace_ms (default 30,000, must be greater than zero)
[storage]StorageConfigmedia_root, resolved against the config file’s directory if relative
[packaging]PackagingConfigsegment_duration_ms (default 6000, must be greater than zero)
[logging]LoggingConfiglevel, format (json or compact), buffer_capacity
[limits]LimitsConfigResource limits, all greater than zero (table below)
[cors]CorsConfigSee TDD 0003
[assets.<id>]AssetConfigpath relative to media_root; static resolver only
[resolver], [resolver.http]ResolverSettings, MapperConfigtype = "static" (default) or "http"; the two forms are mutually exclusive with [assets] and are validated together
[registry]RegistryConfigResolution cache size, load queue timeout, and preload
[remote_media]RemoteMediaConfigHost allow-list, address policy, and limits for http locations

Asset resolution. The media root is canonicalized and must be a directory. Each asset ID must be ASCII letters, digits, -, or _ (validate_asset_id) and each path must be relative. The path is joined to the root and canonicalized (which resolves symlinks), and the result must still start with the root and be a regular file. This blocks .. and symlink escapes. At least one asset is required, and the count is capped by limits.max_assets.

Limits

LimitDefaultEnforced in
max_assets1,000Config::parse
max_source_bytes1 TiBmp4::parse
max_metadata_bytes64 MiBMetadata::fetch: moov plus every moof
max_fragments20,000Metadata::fetch: moof boxes in one fragmented file
metadata_concurrency16Metadata::fetch: fragments fetched at once through a sidx
tolerate_truncated_tailoffMetadata::fetch: drop a fragment the file is cut off inside, instead of refusing the file
max_tracks8mp4::parse
max_samples_per_track2,000,000mp4::parser::parse_samples
max_samples_per_segment100,000segment::plan
max_segment_bytes64 MiBfmp4::prepare_media_segment
max_segment_jobs2 per CPU, max 32AppState.segment_jobs
segment_queue_timeout_ms2,000acquire_segment_permit
stream_chunk_bytes256 KiBStreamJob
max_request_header_bytes16 KiBenforce_header_limit
request_timeout_ms30,000TimeoutLayer
max_startup_parses4Concurrent asset loads in the registry (also bounds startup preload)
max_concurrent_requests10,000shed_load
response_idle_timeout_ms30,000StreamJob::send
max_index_bytes4 GiBAppState::load
max_connections10,000http/server.rs accept loop
header_read_timeout_ms10,000hyper HTTP/1 header read, via http/server.rs

LimitsConfig::validate rejects a zero in any of them.

Design note: CorsConfig stores plain strings and is validated logically here (non-empty lists, no * mixed with origins, no credentials with wildcards, origin shape) while the parsing into HTTP types happens in http::cors_layer.

Adding an option: add the field to the raw struct with a default, validate it in parse, expose it on Config if needed, document it in vod.example.toml, and add a test in the config tests using the parse_with helper.

error.rs : the error type

One Error enum built with thiserror, and a Result<T> alias.

VariantMeaningHTTP mapping
InvalidRangeA byte range fell outside a source500
InvalidMedia(String)The file is malformed or inconsistent500 at request time, startup failure at load
Unsupported(String)Valid but unsupported media (codec, edit list, encryption, …)Startup failure at load
NotFound(&'static str)A track or segment does not exist404
Upstream(String)A remote origin or mapper misbehaved or was refused502
UpstreamUnavailable(String)A remote origin timed out or is overloaded (retryable)503
Io, Mp4, TomlWrapped library errors500
Configuration(String)Invalid configurationStartup failure
Logging(String)Logger initialization failureStartup failure

Use InvalidMedia for “the file is broken” and Unsupported for “the file is fine but we do not handle it”. Use NotFound only for things a client can legitimately ask for and not find.

observability/logging.rs : non-blocking logs

logging::init(&LoggingConfig) returns a LoggingGuard that must stay alive for the process lifetime.

  • Logs go through tracing-appender’s non-blocking writer with .lossy(true) and buffered_lines_limit(buffer_capacity). A dedicated writer thread does the actual stdout writes. When the queue is full, new lines are dropped instead of blocking request tasks.
  • The subscriber is JSON (flattened fields, current span included) or compact text, filtered by the configured level.
  • A monitor thread wakes every 10 seconds, copies the appender’s dropped-line count into a static atomic (read by /metrics through dropped_lines()), and prints a warning to stderr if it grew. It writes to stderr directly because the log queue is the thing that is saturated.
  • Dropping the guard stops the monitor and flushes the writer.

Level policy: info for lifecycle and asset loads, debug for per-request events, warn for rejected requests, error for internal failures. Never log at trace per sample or log media payload.

observability/metrics.rs

Described with the HTTP layer in HTTP server.

main.rs, lib.rs, and cli/ : entry point and CLI

main.rs is ten lines: a Tokio #[tokio::main] that calls segmentor::run() and returns its ExitCode. lib.rs declares the modules, defines APP_NAME, and implements run(), which calls cli::run with the process arguments and, on failure, prints segmentor: <error> and returns a failure code.

cli::run reads the first argument:

  • none: prints segmentor (used by tests/cli.rs);
  • serve --config <file> (cli/serve.rs): Config::load, logging::init, then http::serve;
  • package --input <mp4> --output <dir> [--segment-duration-ms N] (cli/package.rs).

Argument parsing is hand-written in ServeOptions::parse and PackageOptions::parse; unknown flags are errors.

package is a synchronous developer tool that runs the same pipeline as the server: open, mp4::parse, segment::plan, then for each track write <track>-init.mp4 and <track>-<n>.m4s using fmp4::write_init_segment and fmp4::write_media_segment, and print a summary. tests/package.rs runs it twice and asserts identical bytes (determinism).

fuzzing.rs exposes exercise_media_pipeline(path) and max_input_bytes() (hidden from rustdoc) so the fuzz crate can drive parse, plan, init writing, and fragment preparation without including source files by path.

Testing

Layers

LayerWhereWhat it proves
Unit tests#[cfg(test)] modules beside the codeParsing, planning, rendering, config validation, range logic
Registry and mapper testssrc/registry/tests.rs with src/testutil.rsResolution, caching, single flight, revalidation, stale-if-error, mapper failures, and remote media, against in-process mapper and origin servers
Router testssrc/http/tests.rsReal routing, middleware, headers, and streaming, through tower::ServiceExt::oneshot (no socket)
Decode testhttp::tests::ffmpeg_decodes_hls_and_dash_presentationsFFmpeg plays the HLS and DASH output over a real TCP server
Process teststests/cli.rs, tests/package.rsThe compiled binary runs; package output is well-formed and deterministic
Conformancetests/conformance.rsPlaylist grammar, timelines, fMP4 box arithmetic, HLS/DASH byte identity, and decoding, against the running binary
Performancebenches/budgets.rsThe TDD 0001 latency, memory, and concurrency budgets (make bench)
Fuzzingfuzz/fuzz_targets/media_pipeline.rsParsing, planning, and fragment preparation survive arbitrary bytes

Run everything CI runs with make ci (format check, cargo check, Clippy with warnings denied, all tests, fuzz-target compile, rustdoc with warnings denied). make test runs only the tests.

Fixtures

Committed under tests/fixtures/, generated by tests/fixtures/generate.sh and tests/fixtures/generate-variants.sh with FFmpeg (make fixtures) from synthetic sources, so there are no licensing concerns:

FilePurpose
h264-aac.mp4Main fixture: 320x180, 30 fps, 3 seconds, keyframe every second, two B-frames, 48 kHz AAC
h264-aac.ffprobe.jsonFFprobe packet dump: the ground truth the index is compared against
h264-aac-moov-last.mp4moov after mdat
h264-aac-edit-list.mp4Edit lists written by -c copy remuxing
h264-aac-default-edits.mp4FFmpeg’s default output: an edit list per track for B-frame delay and AAC priming
h264-aac-audio-delay.mp4Audio starts half a second late, so its track has a leading empty edit
h264-aac-two-audio.mp4Two audio tracks, tagged eng and spa
h264-aac-timecode.mp4An extra tmcd track that must be skipped
h264-aac-anamorphic.mp4Non-square pixels and tagged colour: the sample entry carries pasp and colr
h264-aac-quicktime.movQuickTime: versioned mp4a entries with a wave box, and a qt brand
hevc-aac.mp4HEVC video (hvc1)
vp9-opus.mp4, av1-aac.mp4VP9 with Opus, and AV1 with AAC
h264-ac3.mp4, h264-eac3.mp4, h264-flac.mp4AC-3, E-AC-3, and FLAC audio
aac-only.m4a, aac-two-tracks-only.m4aAudio only, with FFmpeg’s default edit list, and two tagged tracks
h264-mp3.mp4MP3 in MP4: an mp4a entry that must be rejected by its audio object type
h264-aac-fragmented*.mp4The progressive fixture remuxed with -c copy into fragmented layouts: two tracks per moof, explicit base offsets (legacy), one track per moof (CMAF), a sidx, version 1 trun with negative composition offsets, and (patched by generate-variants.sh) a timeline starting at 100 seconds
h264-video-only.mp4No audio track
h264-aac-44100-stereo.mp4Different audio parameters
h264-variable-timing.mp4Two frame-duration classes

FFmpeg is needed only to regenerate fixtures and to run the decode test, which skips itself with a message when ffmpeg is not installed. CI installs it.

Techniques used here

  • Real servers, not fakes. MockMapper and MockOrigin in src/testutil.rs are Axum servers on ephemeral ports. The mapper can be told to answer a given status, delay, a raw body, or require a token; the origin can ignore ranges, advertise a chosen validator, change it mid-test, and counts requests and bytes served. Tests then assert on observable behavior: mapper call counts, origin range requests, response statuses, and metrics.

  • Mutation checks. Removing single flight or the DNS address filter makes the concurrency and SSRF tests fail, which is how those tests were validated.

  • Oracle comparison: sample_index_matches_ffprobe_packets checks every sample’s offset, size, timestamps, and keyframe flag against FFprobe.

  • Equivalence with the progressive file: every fragmented fixture holds the same packets as h264-aac.mp4, and a test compares each sample’s size, duration, sync flag, and payload bytes, and its timing.

  • Second implementation: agrees_with_an_independent_implementation_on_every_sample compares the in-tree parser with the mp4 crate (a dev-dependency only) on seven fixtures: timing, sync flags, and the payload bytes at each sample’s offset and size.

  • Verbatim init segments: the init tests assert that the stsd bytes equal the source’s, so pasp, colr, and avcC survive; the conformance suite then compares aspect ratio and colour reported by FFprobe for the source and the reassembled track.

  • Deterministic mutation: corrupted_metadata_never_panics_the_pipeline corrupts a few random bytes of each fixture’s moov thousands of times and runs parse, plan, init writing, and fragment preparation over each. Any result is fine except a panic or hang. It runs in every cargo test; the fuzz target below is the deeper version.

  • Byte mutation instead of new fixtures: tests copy moov from the fixture and patch a few bytes to make an invalid input (find_type locates a box by name). See rejects_multiple_sample_descriptions and rejects_run_length_entries_that_claim_more_samples_than_stsz.

  • State injection for limits: HTTP tests build an AppState with small limits, then hold semaphores (segment_jobs, request_slots) to force 503 paths, or shrink stream_chunk_bytes and response_idle_timeout_ms to exercise the idle-client path.

  • Versioned URLs in tests: the helper versioned(path) appends the real ?v= value that media routes require.

  • Determinism: tests/package.rs runs the packager twice and compares every output byte.

Fuzzing

The fuzz crate (fuzz/) is separate and depends on the library. Its target writes the fuzz input to a temporary file and calls segmentor::fuzzing::exercise_media_pipeline, which runs parse, plan, init-segment writing, and fragment preparation and ignores expected failures; the fuzzer looks for panics, hangs, and runaway allocation. make fuzz-check compiles the target on stable; make fuzz runs a campaign on nightly with the fixtures as seeds.

Adding a test

  1. Put unit tests in the module’s tests block using the fixtures above.
  2. For a new rejection rule, mutate fixture bytes rather than adding a binary.
  3. For a new route or header behavior, add a router test with oneshot in http/tests.rs.
  4. Anything that changes fragment or playlist bytes should also keep the FFmpeg decode test and tests/package.rs green.

Not covered yet

The vendor HLS and DASH validators and browser playback tests; see Protocol conformance. Performance results and their limits are in Performance budgets.

cargo test builds the benchmark binary but skips its run unless invoked with --bench, so a minute-long load test never runs as part of the test suite.

Code organization

A review of the source layout and the reorganization plan. Steps 1 to 3 are done; steps 4 to 6 remain proposals.

Current layout

src/
  main.rs            10 lines: calls segmentor::run()
  lib.rs             module declarations, run(), APP_NAME
  fuzzing.rs         hidden entry points for the fuzz target
  benchmarking.rs    hidden entry points for benches/ (real asset loader and server)
  cli/               mod.rs (dispatch), serve.rs, package.rs
  config/            mod.rs (Config, parsing, tests), limits.rs, cors.rs, logging.rs
  http/              mod.rs (serve), state.rs, router.rs, middleware.rs, error.rs,
                     cors.rs, range.rs, validators.rs, server.rs, shutdown.rs, stream.rs,
                     handlers/{mod,health,playlist,media}.rs, tests.rs
  asset.rs           PackagedAsset: source, index, plan, init segments, rendered text
  resolver/          mod.rs (types), catalog.rs (static), mapper.rs (client), policy.rs (trust rules)
  registry/          mod.rs (single flight, caches), cache.rs (byte-weighted LRU), opener.rs, tests.rs
  protocol/          mod.rs, presentation.rs (read-only view), hls.rs, dash.rs
  observability/     mod.rs, logging.rs, metrics.rs
  error.rs
  source/            mod.rs, local.rs, http.rs (remote), metadata.rs (the `moov` box)
  mp4/               parser.rs (orchestration), boxes.rs (bounded box walker), tables.rs (sample tables),
                     codec.rs (codec configuration), edit.rs (edit lists),
                     fragments.rs (samples from `moof` boxes)
  media/  segment/  fmp4/           one focused file each (mod.rs is only re-exports)
  testutil.rs        mock mapper and origin servers (tests only)
tests/               cli.rs, package.rs, fixtures/
fuzz/                separate crate that depends on the library

Status of the reorganization

StepState
1. lib.rs plus a thin main.rs; fuzz depends on the libraryDone. The public surface is run() and the hidden fuzzing module; everything else stays pub(crate)
2. package command moved into cli/Done
3. http.rs split into http/Done. Largest production file is about 200 lines
4. config.rs into config/Done. Limits, CORS, and logging settings each have a file
5. hls/dash into protocol/ with a read-only viewDone. Renderers take a Presentation (tracks, plan, version) and no longer depend on asset
6. logging/metrics into observability/Done. The router owns the route table and passes it to Metrics::new

All six steps were behavior-preserving and make ci stayed green. The test count went from 59 to 61 because the moves added two tests: a renderer test that a missing track is NotFound, and a check that route templates are unique.

Notes on how the result differs from the original proposal:

  • ETag and If-None-Match handling lives in http/validators.rs, separate from range.rs.
  • The router tests are one http/tests.rs file rather than spread across modules, because they drive the whole router.
  • Route table. http/router.rs defines each route template once as a constant and builds ROUTES from them. The same constants register the handlers and label the metrics, so adding a route can no longer leave metrics out of date. Metrics::new(&ROUTES) sizes its counters from that list, with two extra slots for unmatched and other.
  • Renderer independence. PackagedAsset::load builds a Presentation from the index, plan, and version, renders all text, and only then constructs the asset. That removed the earlier two-phase construction where an asset was created with empty text and then filled in. Bandwidth calculation moved from PackagedAsset into Presentation.
  • Renderer tests build a Presentation from the fixture directly and no longer load a full asset.

Assessment

What works well:

  • The media pipeline is cleanly layered. source, media, mp4, segment, and fmp4 have one job each, no HTTP knowledge, and a one-directional dependency order. New contributors can work in one without reading the others.
  • Files are small everywhere except one. Apart from http.rs, every file is a few hundred lines with a single responsibility.
  • Tests sit next to the code and the tests directory is small and purposeful.

What will not scale:

  1. http.rs mixes about ten concerns in one 1,600-line file: state loading, router and layer wiring, four middlewares, playlist handlers, segment handlers, the streaming task, range and ETag parsing, CORS construction, the error type, shutdown, and a long test module. Any HTTP change touches it, merge conflicts are likely, and reviewers cannot see boundaries.
  2. The crate is binary-only. There is no lib.rs. Consequences:
    • the fuzz target must include source files with #[path], which is brittle (it forces config.rs to stay free of HTTP dependencies, for example);
    • benchmarks (needed for the pending performance budgets) and integration tests cannot import the packaging code;
    • pub(crate) everywhere means no enforced API boundary.
  3. main.rs holds application code: the package command is real logic (about 80 lines) living in the entry point.
  4. Cross-cutting infrastructure is at the top level. metrics.rs and logging.rs are observability, and metrics.rs hard-codes the HTTP route list, coupling it to http.rs.
  5. hls/dash and asset reference each other, a small cycle: asset calls the renderers, and the renderers read PackagedAsset.
  6. Upcoming work will worsen these. TDD 0002 adds a registry, a resolver, a mapper client, an HTTP media source, and more config. Without a structure for it, all of it lands in http.rs, config.rs, and asset.rs.

Verdict (written before steps 1 to 3): manageable, but worth reorganizing before the mapper work. The HTTP layer, crate boundary, and CLI were the problem areas and are now addressed. The assessment below is kept as the rationale.

src/
  main.rs                  thin: parse args, call into the library
  lib.rs                   module declarations; small, documented public surface
  cli/
    mod.rs                 command dispatch
    serve.rs               `serve` options and startup
    package.rs             `package` command (moved from main.rs)
  config/
    mod.rs                 Config, load/parse
    limits.rs              LimitsConfig
    cors.rs                CorsConfig
    logging.rs             LoggingConfig
  error.rs
  media/                   MediaIndex, Track, Sample           (unchanged)
  source/                  MediaSource, local                  (unchanged; remote source lands here)
  mp4/                     parser                              (unchanged)
  segment/                 planner                             (unchanged)
  fmp4/                    init, fragment                      (unchanged)
  asset/
    mod.rs                 PackagedAsset
    render.rs              RenderedManifests
    bandwidth.rs           Bandwidth calculation
  protocol/
    hls.rs
    dash.rs                renderers move here, next to (not inside) asset
  http/
    mod.rs                 serve(), re-exports
    state.rs               AppState, load
    router.rs              routes and layer order
    middleware.rs          request_id, metrics, shed_load, header limit
    handlers/
      health.rs            health, ready, metrics
      playlist.rs          master, media playlist, DASH manifest
      media.rs             init_segment, media_segment
    stream.rs              StreamJob
    range.rs               ETag, If-None-Match, Range, If-Range
    cors.rs                cors_layer
    error.rs               HttpError
    shutdown.rs            shutdown_signal
  observability/
    logging.rs
    metrics.rs             route list defined by http/router.rs, passed in
tests/                     integration tests can now `use segmentor::...`
benches/                   criterion benchmarks for the performance budgets
fuzz/                      depends on the library crate, no #[path] includes

Key points:

  • lib.rs plus a thin main.rs. The binary becomes about ten lines. The fuzz crate and benchmarks depend on the library like any other crate, removing the #[path] workaround and the “no HTTP crates in config” constraint.
  • http/ split by concern. Each file has one reason to change, tests move beside the code they cover (for example range tests into range.rs), and the large router test module becomes a tests/http_*.rs integration suite that can share a small helper module.
  • protocol/ breaks the asset/renderer cycle. Renderers take a read-only view (tracks, segments, version, bandwidth) and return text; asset calls them; protocol no longer depends on asset’s internals.
  • observability/ groups logging and metrics, and metrics stops owning the route table.
  • Visibility. Make the library’s public surface deliberate: pub for what the binary, benches, and fuzz need, pub(crate) for the rest.

Suggested order (all steps done)

Each step is behavior-preserving and keeps make ci green, so it can ship as its own small change.

  1. Add lib.rs, make main.rs thin, point fuzz at the library. (Unblocks benchmarks.)
  2. Move the package command into cli/.
  3. Split http.rs into http/ (start with pure pieces: range.rs, error.rs, cors.rs, shutdown.rs; then stream.rs; then handlers and middleware).
  4. Split config.rs into config/.
  5. Move hls/dash to protocol/ and pass a read-only view.
  6. Group logging/metrics under observability/.

This was done before the mapper work so the registry and resolver can arrive as new modules (registry/, resolver/) instead of edits to existing large files.

What not to do

  • Do not create a Cargo workspace of many crates yet. The code is one deployable, compile times are small, and cross-crate visibility would add friction without a second consumer.
  • Do not introduce traits for their own sake. The media pipeline uses concrete types on purpose; the one trait that matters (MediaSource, becoming an enum in TDD 0002) should stay minimal.
  • Do not reorganize and change behavior in the same change.

How nginx-vod-module works

Kaltura’s nginx-vod-module is an NGINX module written primarily in C. It is a useful reference architecture for on-demand packaging, but this project does not copy its source or require its runtime.

Licence and provenance. nginx-vod-module is licensed under the AGPL-3.0, which is not compatible with this project’s licence (MIT or Apache-2.0). This page describes it from its public documentation, listed under References, and from its observable behaviour. Nothing in segmentor is derived from its source, and the contributing guide forbids copying, translating, or adapting it. Design decisions here come from the specifications and from that behaviour.

NGINX supplies the HTTP server, event loop, request routing, file and upstream I/O, buffer chains, and response filters. The module supplies its own media pipeline:

  1. Resolve a local path, remote HTTP source, or mapped media-set description.
  2. Read and parse MP4 metadata, including sample tables and codec configuration.
  3. Cache metadata so segment requests do not repeatedly parse the source.
  4. Select tracks and calculate segment boundaries, optionally aligning them to keyframes.
  5. Generate HLS, DASH, MSS, or HDS manifests with protocol-specific code.
  6. Generate segment container headers with its own HLS, DASH, MP4, and MPEG-TS writers.
  7. Read the selected encoded frames and send them through NGINX buffer chains.

Does it use FFmpeg?

The normal MP4-to-HLS or MP4-to-DASH path does not invoke an ffmpeg command and does not require FFmpeg libraries. nginx-vod-module parses MP4 and writes output containers itself, preserving encoded samples when no filter requires decoding.

FFmpeg is an optional build dependency for features that need codec processing:

  • thumbnail decoding uses libavcodec, with resizing through libswscale;
  • volume-map generation decodes audio with libavcodec;
  • playback-rate, gain, and mixing filters use libraries such as libavcodec and libavfilter;
  • some audio filtering configurations also require an encoder such as libfdk_aac.

OpenSSL, rather than FFmpeg, supplies optional encryption and decryption support. The architectural lesson for this service is to keep packaging independent from decoding. FFmpeg is a development-time fixture and validation tool here, not a production packaging dependency.

Performance lessons

nginx-vod-module caches MP4 metadata, keeps the packager close to source storage, uses asynchronous I/O, and expects generated media to be cached by proxies or a CDN. Those principles apply here, but their implementation must fit Axum, Tokio, immutable Rust data, and this project’s explicit resource limits.

References

Architectural decision records

ADRs record important architectural choices and the reasoning available when each choice was made.

Decisions

IDTitleStatus
0001Use fragmented MP4 as the initial media segment formatAccepted

Status values

  • Proposed: under discussion and not yet binding.
  • Accepted: the current direction for implementation.
  • Deprecated: still present but no longer recommended.
  • Superseded: replaced by a later ADR, which must be linked.
  • Rejected: considered and deliberately not selected.

Workflow

  1. Copy template.md to the next zero-padded number.
  2. Describe the context and competing constraints, not only the selected technology.
  3. Record meaningful alternatives and consequences.
  4. Merge an ADR as Proposed or Accepted.
  5. Never rewrite an accepted decision to hide history; add a superseding ADR instead.

ADR 0001: Use fragmented MP4 as the initial media segment format

  • Status: Accepted
  • Date: 2026-09-10
  • Decision owners: Project maintainers
  • Related designs: TDD 0001

Context

The service must package compatible MP4 files on demand for MPEG-DASH and HLS with high throughput and without transcoding. DASH commonly carries ISO Base Media File Format fragments. HLS can carry either MPEG-2 Transport Stream or fragmented MP4.

Maintaining unrelated segment writers for the first DASH and HLS versions would duplicate timing, sample selection, buffering, and validation logic.

Decision

Use CMAF-oriented fragmented MP4 as the initial media segment format for both DASH and HLS.

The packaging core will generate protocol-neutral initialization segments and media fragments from one segment plan. DASH MPDs and HLS playlists will be separate adapters over those shared artifacts. The initial implementation will support only a documented subset of codecs and MP4 features that can be safely transmuxed.

This decision does not claim complete CMAF conformance until automated conformance tests exist. It establishes CMAF compatibility as the design target.

Consequences

Positive

  • One sample-index, segmentation, and fragment-writing path serves both protocols.
  • Encoded sample payloads can usually be copied unchanged from the source MP4.
  • HLS and DASH outputs can share timing and cache behavior.
  • Fragmented MP4 provides one modern media path for both supported VOD protocols.
  • Fragmented MP4 avoids implementing MPEG-TS packetization in the initial core.

Negative

  • Older HLS clients that require MPEG-TS will not be supported initially.
  • Correct moof construction, decode timing, composition offsets, and data offsets require careful validation.
  • Input codecs and sample descriptions must satisfy both the selected HLS and DASH compatibility profiles.
  • Formal CMAF conformance adds constraints beyond merely producing playable fragmented MP4.

Alternatives considered

MPEG-TS for HLS and fragmented MP4 for DASH

This maximizes compatibility with older HLS clients but requires two media segment writers and separate timestamp/container behavior. It remains a possible later compatibility feature.

Pre-package all assets

Pre-packaging simplifies request-time work but duplicates media in storage, delays asset availability, and conflicts with the goal of packaging arbitrary source MP4 files on demand. Generated outputs may still be cached externally.

Delegate all packaging to FFmpeg

FFmpeg is valuable as a reference and validation tool, and may be appropriate for transcoding workflows. Running a packaging process per segment request adds process, startup, resource-control, and streaming complexity and does not provide the intended Rust-native metadata cache and request path.

Transcode while serving

Transcoding can normalize arbitrary input and create bitrate ladders, but it changes the service into a compute-heavy encoder. It is outside the first packaging core and may later be handled by a separate preprocessing service.

Follow-up

  • Define the initial H.264/AAC input and CMAF compatibility profile.
  • Validate generated fragments with FFmpeg, HLS tools, DASH tools, and representative players.
  • Record a separate ADR before adding MPEG-TS output or claiming full CMAF conformance.

ADR NNNN: Title

  • Status: Proposed
  • Date: YYYY-MM-DD
  • Decision owners: Project maintainers
  • Related designs: None

Context

Describe the forces and constraints that require a decision.

Decision

State the selected direction precisely.

Consequences

Positive

  • Consequence

Negative

  • Consequence

Alternatives considered

Alternative

Explain why it was not selected.

Follow-up

  • Action or future decision

API reference

The application API reference is generated from Rust source comments with rustdoc. The complete documentation site places it alongside this book.

Open the API reference

For a local copy, run make site and open target/book/index.html. The API link above will then resolve to the generated rustdoc pages.