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.
- Technical design index
- On-demand MP4 packaging core
- Asset mapper interface
- Production-grade HTTP API
- Technical design template
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.
- Implementation guide: module-by-module description, lifecycles, and concurrency model.
- Code organization: review of the source layout and a proposed reorganization.
- Design records: packaging core, asset mapper, production-grade HTTP API, and the ADR index.
- Operating the origin for deployment.
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 | |
|---|---|
| Video | H.264, HEVC (hvc1/hev1), VP9, AV1 |
| Audio | AAC-LC, HE-AAC and HE-AACv2 (explicit signaling), AC-3, E-AC-3, Opus, FLAC |
| Layout | one video track and any number of audio tracks, or audio only; edit lists of one edit, optionally after one empty edit |
| Skipped | tracks that are not audio or video (timecode, metadata, subtitles) |
| Rejected | encrypted 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 when | Files | |
|---|---|---|
| Container | You run services under Docker, Compose, or Kubernetes. This is the way most people should run it. | deploy/docker-compose.yml, deploy/kubernetes/ |
| Release binary | You run on a plain Linux host under systemd, or want no container runtime. | install.sh, deploy/systemd/ |
| From source | You 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
vquery parameter that changes whenever the media does, and are servedCache-Control: public, max-age=31536000, immutable. Playlists and manifests are served with a shortmax-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:
| Path | Holds |
|---|---|
/etc/vod/vod.toml | The configuration, read-only. Set server.listen = "0.0.0.0:3000", since inside a container 127.0.0.1 is unreachable. |
/srv/vod | The 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.
/healthis liveness./readyis readiness and also the startup probe, and it turns503when shutdown begins, which is what takes a terminating pod out of the Service before its connections close.terminationGracePeriodSecondsis 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
/readytakes each out of rotation as it stops. - Expect new URLs. The
vin 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_requestsanswers503withRetry-After: 1once that many handlers are running./health,/ready, and/metricsare exempt. -
limits.response_idle_timeout_mscloses 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 exhaustlimits.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, orLimitNOFILEunder systemd) above this number plus headroom for media file handles; otherwise accept fails withEMFILEbefore the cap applies (the accept loop then logsaccept_failedand 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
| Path | Meaning | Use for |
|---|---|---|
/health | The process is running | Liveness probe |
/ready | 200 while serving, 503 once shutdown begins | Readiness probe and load-balancer health check |
/metrics | Prometheus text | Scraping |
Shutdown
The service handles SIGTERM and SIGINT. On either signal it:
- flips
/readyto503; - keeps accepting connections for
server.shutdown_delay_ms, so a load balancer can notice and drain; - stops accepting new connections and lets in-flight responses finish;
- exits after at most
server.shutdown_grace_msmore, 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
| Metric | Type | Notes |
|---|---|---|
vod_http_requests_total{route,status} | counter | route is the route template, so cardinality is fixed |
vod_http_request_duration_seconds{route} | histogram | Time to response headers, not body transfer |
vod_http_requests_in_flight | gauge | |
vod_http_response_bytes_total | counter | Bytes handed to the response stream |
vod_source_read_bytes_total | counter | Compare with response bytes to check read amplification |
vod_http_requests_shed_total | counter | Requests refused by max_concurrent_requests |
vod_http_connections_open | gauge | Open TCP connections |
vod_http_connections_rejected_total | counter | Connections closed at accept because of max_connections |
vod_segment_queue_timeouts_total | counter | Waits that exceeded segment_queue_timeout_ms |
vod_segment_stream_aborts_{idle,client,error}_total | counter | Streams that ended early, by cause |
vod_log_dropped_lines_total | counter | Log 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_msandmax_ttl_ms), revalidated withIf-None-Match, and a missing asset is remembered fornegative_ttl_ms. - A mapper outage does not stop playback of known assets. An expired answer is served for up to
stale_if_error_mswhile the mapper is down. A location with anexpires_at(a signed URL) is never served past that time. Unknown assets return503until the mapper recovers. - An upgrade can change URLs. The
vin a media URL hashes everything the index was built from (moov, and everymoofof 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 = trueto serve the complete fragments before the cut instead; each such load logstruncated_tail_droppedwith 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
404and refetch the playlist. - Set
readiness_probe_interval_msif you want/readyto report503while the mapper is unreachable, so a load balancer can hold new traffic./healthis 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_hostsmust list every origin host; an empty list refuses all remote locations.- Locations must be
https(allow_insecure_httpis 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
Rangeand send a strongETagor aLast-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_atand re-sign under the sameversion. 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 with401,403, or410. Watchvod_location_rotations_total.
TLS uses the operating system’s trusted roots (the container image carries a CA bundle).
Mapper and registry metrics
| Metric | Type | Notes |
|---|---|---|
vod_resolver_requests_total{outcome} | counter | ok, unchanged, not_found, unavailable, rejected |
vod_resolution_cache_events_total{event} | counter | hit, miss, revalidate, stale, negative_hit |
vod_asset_loads_total{outcome} | counter | ok or failed |
vod_asset_load_seconds_total | counter | Divide by loads for the mean load time |
vod_location_rotations_total | counter | Signed URLs replaced in place on loaded assets |
vod_registry_coalesced_waiters_total | counter | Requests that shared another request’s resolve or load |
vod_loaded_assets, vod_loaded_bytes | gauge | What 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-chefcompiles dependencies from a recipe derived fromCargo.tomlandCargo.lock, so a source-only change rebuilds only the application. BuildKit cache mounts keep the cargo registry between builds. - Reproducibility. Builds use
--locked. PinRUST_IMAGEandRUNTIME_IMAGEto 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
SIGTERMdirectly. - Build context.
.dockerignoreadmits only the manifests andsrc/, 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.
| Variable | Default | Meaning |
|---|---|---|
BUDGET_STREAMS | 1000 | Concurrent streaming clients |
BUDGET_SECONDS | 10 | Duration of the streaming test |
BUDGET_WORKERS | 4 | Tokio worker threads, mirroring the four-core reference host |
What is measured
| Measurement | Budget | How |
|---|---|---|
| Warm load (open, parse, plan, init segments, render) of a 60-minute asset | p95 < 250 ms | 11 repeated loads after one discarded cold load |
| Warm 6 s segment header generation | p95 < 10 ms | 3,000 prepare_media_segment calls across the timeline |
| Cached HLS master, HLS media, and DASH responses | p95 < 2 ms | 3,000 sequential requests each over one keep-alive connection |
| Source bytes read beyond the payload | <= 256 KiB per segment | vod_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 KiB | Peak 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.
| Measurement | Budget | Result |
|---|---|---|
| Warm load, 60-minute asset | p95 < 250 ms | p50 85 ms, p95 121 ms |
| Cold first load | - | 110 ms |
| 6 s header generation | p95 < 10 ms | p95 0.006 ms |
| HLS master / HLS media / DASH (loopback, includes client) | p95 < 2 ms | p95 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 KiB | 0 |
| 1,000 sustained streams | < 0.1 % errors | 0 of 6,302 requests; 598 req/s, 116 MiB/s |
| Extra memory per streaming connection | <= 512 KiB | 66 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 withEXT-X-MAPrequires. - The variant has
BANDWIDTHnot belowAVERAGE-BANDWIDTH, a codec string, and a resolution. - An
AUDIOgroup reference on the variant matches exactly oneEXT-X-MEDIArendition, and a rendition without a reference is rejected. - Media playlists are
VOD,INDEPENDENT-SEGMENTS, end withENDLIST, and have anEXT-X-MAP. - Every
EXTINFrounded to whole seconds does not exceedEXT-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 validmediaPresentationDurationandminBufferTime. - Each
Representationhas bandwidth and codecs and aSegmentTemplatewith aSegmentTimeline. $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 onetrak,mvex/trex, and empty sample tables. - Media segments are
[styp] moof mdat, every box size tiles its parent exactly, andtfhduses default-base-is-moof. - The
trundata offset lands exactly on the first payload byte, and the sample sizes sum to themdatpayload. mfhdsequence numbers run 1, 2, 3 …;tfdtvalues 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.
| Check | Status | How to run it |
|---|---|---|
Apple HLS validation (mediastreamvalidator, hlsreport) | Not run | Requires 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 run | Needs Java or the hosted validator. Run it on a served MPD before a release |
| CMAF (ISO/IEC 23000-19) profile conformance | Not claimed | The 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 implemented | Needs 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 covered | Manual |
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
| Method | Path | Purpose |
|---|---|---|
GET | /v1/assets/{asset_id} | Resolve one asset |
GET | /v1/health | Optional 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=..." }
}
| Field | Required | Rules |
|---|---|---|
asset_id | Yes | Must equal the requested ID exactly |
version | Yes | 1 to 256 visible ASCII characters (no spaces). Any change to the media must change it; equal versions are assumed to be identical media |
ttl_seconds | No | How 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_at | No | RFC 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.type | Yes | file or http. Anything else is rejected |
location.path | For file | Relative to storage.media_root; no leading /, no . or .. components, no NUL, at most 4096 bytes |
location.url | For http | See Remote locations |
subtitles | No | Sidecar 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
| Status | Meaning | What the server does |
|---|---|---|
404 or 410 | The asset does not exist (or no longer does) | Serves 404 to players, caches the absence briefly, and drops any loaded copy |
401 or 403 | The server’s credentials are wrong | Serves 502, logs at error |
429 | The mapper is shedding load | Retries after Retry-After (capped at one second), then serves 503 |
5xx, timeout, connection failure | The mapper is unhealthy | Retries 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 violation | Malformed answer | Serves 502 and never caches the answer as valid |
Any other 4xx | Contract violation | Serves 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(orhttpif the operator enabled it for development); - the host must be in the server’s
remote_media.allowed_hostslist, 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
Rangerequests with206and a correctContent-Range; - send a strong
ETag, or aLast-Modified, on the response. The server sends it back asIf-Rangeon every read, so an object that changes while it is being read fails the read instead of mixing two versions. A weakETagalone 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
versionand 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, or410to a read (an expired or revoked signature, or clock skew), the server asks the mapper once for a fresh answer, without anIf-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 a502.
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" } }
]
}
| Field | Required | Rules |
|---|---|---|
language | Yes | A 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 |
label | No | What a player shows the viewer. Defaults to the language. At most 128 bytes, no control characters |
default | No | The player selects this one unless the viewer chose otherwise. At most one entry may set it |
forced | No | The track is meant to be shown even when the viewer has not asked for subtitles |
location | Yes | A 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_idechoes the request. versionchanges whenever the media changes, and only then.- Removed assets answer
404or410, not200. - 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.
versionalso changes when a subtitle file changes.expires_atis set for anything signed, and re-signing keeps the sameversion.- The mapper is reachable over
httpsin 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.
| Type | Effect on the version |
|---|---|
feat | Minor bump (1.2.0 to 1.3.0) |
fix, perf | Patch bump (1.2.0 to 1.2.1) |
Any type with ! (refactor(api)!: ...), or a BREAKING CHANGE: line in the body | Major bump (1.2.0 to 2.0.0) |
docs, refactor, test, build, ci, chore, style, revert | No 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:
- It finds the newest
vX.Y.Ztag reachable from that commit. Pre-release tags such asv1.0.0-rc.1are ignored. - It reads the non-merge commits since that tag and picks the highest level from the table above.
- If there is a releasable change it creates an annotated tag
vX.Y.Zon 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:
| Input | Meaning |
|---|---|
tag | The version tag to release, for example v1.3.0. Leave it empty for the newest tag |
channel | latest 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:
- 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.
- Build, once for
x86_64-unknown-linux-gnuand once foraarch64-unknown-linux-gnu, each on a native runner. It checks out the tag, stamps its version intoCargo.tomlandCargo.lockfor the build only, buildssegmentorin release mode with--locked, and packagessegmentor-vX.Y.Z-<target>.tar.gz(the binary,LICENSE-MIT,LICENSE-APACHE,README.md, andvod.example.toml) with a SHA-256 file. - 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.
- Container. Runs the
Container imageworkflow: builds the image natively forlinux/amd64andlinux/arm64, publishes one multi-architecture image toghcr.io/<owner>/segmentor, signs it with cosign, and attests its provenance. It is taggedX.Y.ZandX.Y, pluslatestorpreviewaccording 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_TOKENand 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 areleaseevent. - 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 publishaftercargo packagehas 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
| ID | Title | Status |
|---|---|---|
| 0001 | On-demand MP4 packaging core | Accepted; verification pending |
| 0002 | Asset mapper interface | Accepted; implemented |
| 0003 | Production-grade HTTP API | Accepted; implemented |
| 0004 | Broader MP4 input support | Accepted; implemented |
| 0005 | Fragmented MP4 input | Accepted; implemented |
| 0006 | Trick play, subtitles, and adaptive renditions | Draft |
Workflow
- Copy template.md to the next zero-padded number.
- Keep the document in
Draftwhile major questions remain. - Record architectural choices as ADRs and link them from the design.
- Change the status to
Acceptedbefore implementation becomes the reference behavior. - Use
Supersededwhen 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.
| Capability | Status | Notes |
|---|---|---|
| Local positioned reads | Implemented | Linux read_exact_at; checked source bounds |
| Progressive MP4 sample indexing | Implemented | H.264/AAC sample tables validated against FFprobe |
| Keyframe-aligned segment planning | Implemented | One video track and optional audio track |
| Separate-track fMP4 generation | Implemented | Init segments cached; media segments generated on request |
| HLS VOD | Implemented | Master and media playlists with fMP4 segments |
| TOML asset catalog | Implemented | Loaded and validated at startup |
| Structured non-blocking logs | Implemented | Configurable level/format; bounded lossy queue |
| Direct bounded HTTP range streaming | Implemented | Header plus coalesced source ranges; 256 KiB default chunks |
| Enforceable parser/resource limits | Implemented | Validated TOML limits cover source, metadata, tracks, samples, segments, queues, and headers |
| Source mutation detection | Implemented | Filesystem identity plus pre/post parse moov SHA-256 |
| Explicit encryption rejection | Implemented | Raw preflight also rejects external references and multiple descriptions. Edit lists are applied since TDD 0004 |
| Runtime cache invalidation/reload | Implemented for mapper-resolved assets | See TDD 0002. Static catalog assets are still immutable for the process lifetime |
| Remote HTTP sources and asset mapping service | Implemented | See TDD 0002 and the mapper API |
| DASH VOD | Implemented | Static MPD reuses separate-track fMP4 artifacts |
| Automated HLS/DASH decode suite | Implemented | FFmpeg consumes both protocols over an ephemeral HTTP server |
| Structural HLS/DASH/fMP4 conformance suite | Implemented | tests/conformance.rs; see conformance |
| Formal HLS/DASH conformance tools | Pending | Apple validator and DASH-IF tool not run; required before claiming protocol/CMAF conformance |
| Browser playback suite | Pending | hls.js/dash.js Playwright coverage is not implemented |
| Seeded media pipeline fuzzing | Implemented | One target covers preflight, parsing, planning, init writing, and fragment preparation |
| Async segment streaming with slow-client protection | Implemented | Job slots are held per source read, not per response; response_idle_timeout_ms drops stalled clients |
| Version-enforced immutable URLs | Implemented | v is required on init and media URLs; missing or stale versions return 404 with no-store |
| Configurable CORS | Implemented | [cors] table; exposes range/ETag headers and covers error responses |
| Readiness, SIGTERM drain, request shedding | Implemented | /ready, shutdown_delay_ms/shutdown_grace_ms, max_concurrent_requests |
| Request metrics and request IDs | Implemented | Prometheus counters/histogram with fixed cardinality; X-Request-Id on every response |
| Precomputed playlists and index memory budget | Implemented | Rendered 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
moovand encoded samples in one or moremdatboxes. - Fragmented MP4: an initialization segment plus media fragments containing
moofmetadata andmdatsample 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 condition | Required behavior | Status |
|---|---|---|
| Fragmented MP4 input | Index from the moof boxes | Implemented (TDD 0005) |
| Codec other than H.264/AAC-LC | Reject as unsupported media | Implemented |
| Missing H.264 SPS/PPS | Reject as unsupported media | Implemented |
| More than one video track | Reject during segment planning | Implemented. Several audio tracks are supported since TDD 0004 |
| Subtitle track | Reject as unsupported media | Implemented. Tracks that are not audio or video (timecode, timed metadata) are skipped since TDD 0004 |
| Missing/inconsistent sample tables | Reject as invalid media | Implemented for parsed tables |
| Sample byte range outside source | Reject as invalid media | Implemented |
| Edit list of one edit, optionally after an empty edit | Apply it to the sample timeline | Implemented (TDD 0004) |
| Any other edit list shape | Reject as unsupported media | Implemented (TDD 0004) |
Encrypted encv/enca sample entry | Reject | Implemented |
| External data reference | Reject | Implemented |
| More than one sample description per selected track | Reject | Implemented |
| Source changes while parsing | Discard parse result and fail startup | Implemented |
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
stszorstz2; - sample-to-chunk mapping from
stsc; - chunk offsets from
stcoorco64.
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:
- Choose each video boundary at a random-access sample near the target duration.
- Never begin a video segment on a dependent frame.
- Select audio samples whose decode-time interval corresponds to the video interval.
- Preserve exact per-track timescales; use checked integer rescaling only at boundaries.
- 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
ftypand a fragment-readymoovwith track metadata andmvex/trexdefaults; - one media segment per request, containing an optional
styp, a generatedmoof, and anmdatcontaining 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
EXTINFvalues; EXT-X-MAPpointing to the initialization segment;- fragmented MP4 media segment URLs;
EXT-X-ENDLISTfor 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:
| Method | Route | Content type | Cache policy |
|---|---|---|---|
GET, HEAD | /health | text/plain | Framework default |
GET, HEAD | /ready | text/plain | 200, or 503 once shutdown begins |
GET, HEAD | /metrics | Prometheus text | Framework default |
GET, HEAD | /hls/{asset}/master.m3u8 | application/vnd.apple.mpegurl | public, max-age=60 |
GET, HEAD | /hls/{asset}/{track}/index.m3u8 | application/vnd.apple.mpegurl | public, max-age=60 |
GET, HEAD | /hls/{asset}/{track}/init.mp4?v={version} | video/mp4 or audio/mp4 by track | public, max-age=31536000, immutable |
GET, HEAD | /hls/{asset}/{track}/segments/{index}/media.m4s?v={version} | video/mp4 or audio/mp4 by track | public, 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:
| Condition | Status | State |
|---|---|---|
| Unknown asset, track, or segment | 404 Not Found | Implemented |
| Malformed path parameter | 400 Bad Request | Provided by Axum |
| Unexpected generation/I/O failure | 500 Internal Server Error | Implemented |
| Unsupported configured media | Startup failure | Implemented |
| Request body too large | Not applicable to current read-only routes | Implemented by route shape |
| Single or suffix HTTP byte range on init/media | 206, or 416 when invalid/unsatisfiable | Implemented |
If-Range that does not match the current ETag | Range ignored, full 200 | Implemented |
| Multi-range | 416 Range Not Satisfiable | Deliberate first-release limitation |
Conditional If-None-Match | Strong ETag; tag lists, weak tags, and * match; empty 304 | Implemented |
Handler concurrency above max_concurrent_requests | 503 with Retry-After | Implemented |
Conditional If-Modified-Since | Not supported | Deliberate 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:
- Resolve an opaque asset identifier to an allowed local source.
- Look up metadata by stable source identity.
- Retrieve the startup-loaded immutable index and segment plan.
- Resolve the requested track and segment number through the segment plan.
- 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
MediaIndexand 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_uringorsendfile; 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.
| Limit | Default | Failure behavior | Status |
|---|---|---|---|
| Configured assets | 1,000 | Configuration error | Implemented |
| Source file length | 1 TiB | Unsupported media | Implemented |
| Parsed MP4 metadata | 64 MiB | Invalid media | Implemented |
| Tracks per asset | 8 | Unsupported media | Implemented |
| Samples per track | 2,000,000 | Invalid media | Implemented |
| Samples per segment per track | 100,000 | Invalid media | Implemented |
| Generated media segment payload | 64 MiB | Internal error during startup/request | Implemented |
| Concurrent startup parses | 4 | Queue remaining work in dedicated pool | Implemented |
| Concurrent segment-generation jobs | 2 per logical CPU, maximum 32 | Queue with timeout, then 503 | Implemented and configurable |
| Segment-generation queue wait | 2 seconds | 503 Service Unavailable | Implemented and configurable |
| Request header bytes | 16 KiB | 431 Request Header Fields Too Large | Implemented after HTTP parsing |
| Whole request timeout | 30 seconds | 408 Request Timeout | Implemented |
| Logging queue | 8,192 records | Drop new log records | Implemented 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.
| Measurement | Initial target |
|---|---|
| Warm startup parse and plan, 60-minute asset | p95 below 250 ms per asset |
| Cached master/media playlist response | p95 below 2 ms server time |
| Warm 6-second segment generation, excluding client transfer | p95 below 10 ms |
| Segment bytes read from source | no more than payload bytes plus 256 KiB |
| Additional buffered memory per streaming request | no more than 512 KiB after direct streaming is implemented |
| Sustained concurrent streams | 1,000 with fewer than 0.1% origin 5xx responses |
| Event-loop blocking | no 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_pipelinefuzz 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:
| Check | Status | Evidence or required work |
|---|---|---|
| Source index matches trusted tracks, counts, keyframes, offsets, DTS/PTS, and durations | Implemented | Rust test compares every packet with committed FFprobe JSON |
| Generated init and media fragments parse and decode | Implemented | Package and protocol tests validate generated media with FFmpeg |
| Complete HLS presentation decodes | Implemented | CI-installed FFmpeg consumes the ephemeral HTTP master URL |
| HLS protocol validator accepts output | Pending | Add an automated validator suitable for Linux CI |
| Complete DASH presentation decodes | Implemented | CI-installed FFmpeg consumes the ephemeral HTTP MPD |
| DASH protocol validator accepts output | Pending | Add maintained DASH-IF conformance tooling |
| Browser starts, seeks, and plays HLS | Pending | Add Playwright with pinned hls.js |
| Browser starts, seeks, and plays DASH | Pending | Add Playwright with pinned dash.js after DASH exists |
| Segment response reads only requested source payload | Implemented | Generated header plus only overlapping source ranges are streamed with backpressure |
| Repeated generation is byte-identical | Implemented | Integration test compares every generated artifact byte-for-byte across two runs |
| Release benchmarks meet the stated budgets | Measured on a laptop; reference host pending | make bench; every budget passes on an i7-8550U, see benchmarks. Reference-host record still required |
Fixture coverage is similarly explicit:
| Fixture characteristic | Status |
|---|---|
H.264/AAC-LC, moov before mdat, stco, constant video timing, B-frames | Implemented |
moov after mdat | Implemented |
co64 chunk offsets | Pending |
| Variable frame timing | Implemented with two packet-duration classes |
| Additional AAC sample rates and channel layouts | Implemented for 44.1 kHz stereo alongside 48 kHz mono |
| No-audio video | Implemented |
| Malformed box sizes/counts | Implemented for undersized child boxes and continuously exercised by fuzzing |
| Truncated metadata and sample payload | Implemented |
| Edit lists | Implemented rejection fixture |
| Encrypted sample entries | Implemented 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
- Complete: generate a synthetic H.264/AAC fixture and commit its FFprobe packet oracle.
- Complete for the supported fixture: adopt
mp40.14 and derive exact sample metadata without readingmdatpayloads. - Complete: define and test the protocol-neutral media index.
- Complete: implement checked, keyframe-aligned video and audio segment plans.
- Complete for the supported fixture: generate separate-track init/media fragments and validate them with FFmpeg.
- Complete: serve TOML-mapped HLS through Axum and Tokio with bounded direct range streaming.
- Complete: enforce parser, memory, concurrency, timeout, header, and error-disclosure limits; add source mutation checks and rejection fixtures.
- Complete for functional behavior: replace HTTP whole-segment buffering with bounded backpressured source-range streaming. Load benchmarks remain pending.
- Partially complete: automate HLS and DASH decode over HTTP. Formal conformance and browser tests remain pending.
- Complete: add the static DASH adapter and FFmpeg validation over shared fragments.
- Complete for immutable restart lifecycle: use versioned resource URLs and ETags. Runtime catalog reload remains deferred.
- 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.
ffprobecomparisons for source indexes and generated fragments, followed byffmpeg -v errordecode 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
- Kaltura nginx-vod-module
- Apple HTTP Live Streaming documentation
- RFC 8216: HTTP Live Streaming
- DASH Industry Forum guidelines
- ISO/IEC 14496-12, ISO Base Media File Format
- ISO/IEC 23000-19, Common Media Application Format
- ISO/IEC 23009-1, Dynamic Adaptive Streaming over HTTP
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:
- the Rust-side
AssetResolverinterface that replaces the hard-wired TOML catalog; - the HTTP/JSON wire specification a mapper service must implement;
- the lazy-loading, caching, and failure behavior that follows from resolving assets at request time;
- 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:
| Draft | Implemented |
|---|---|
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 method | Payload 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 spike | Confirmed: 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 key | A 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 question | registry.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 optional | Implemented as resolver.http.readiness_probe_interval_ms; zero disables it |
A weak ETag | Refused 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 = "..."intoBTreeMap<String, PathBuf>and validates every path beneathstorage.media_root. - Before this design,
AppState::loadparsed every asset with a rayon pool before the listener bound, and a bad asset failed startup. (Now:AppState::newpluspreload, see Implementation status.) PackagedAssetin src/asset.rs holds a concreteLocalMediaSource, theMediaIndex, theSegmentPlan, and init segments.AppState.assetsis an immutableHashMap. An unknown ID is a404. There is no cache miss path, no request coalescing, and no reload.SourceIdentityin 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
segmentornever 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.
segmentorstill 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
AssetResolverinterface 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:
| Kind | Meaning | Server handling |
|---|---|---|
file | A path relative to storage.media_root | Same canonicalize-and-contain check as the TOML catalog, then LocalMediaSource::open |
http | A URL that serves the MP4 bytes and supports Range requests | HttpMediaSource (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.
StreamJobin src/http/ keeps its structure (header chunk, then coalesced ranges, each send bounded by the idle timeout) but callsMediaSourceKind::read_rangedirectly instead of wrappingPackagedAsset::read_rangeinspawn_blocking. The local variant performs thatspawn_blockinginternally. - Concurrency gating. The job slot still surrounds each source read. A separate
max_inflight_source_readslimit bounds outbound requests to a remote origin. - Local reads. Unchanged:
read_exact_aton the blocking pool per chunk (about 256 KiB).io_uringremains a later, measured optimization. - Remote reads. One shared client per process, keep-alive, HTTP/2 where offered,
Range: bytes=a-b, response checked for206, matchingContent-Range, and a stable validator (ETagorLast-Modified) sent asIf-Rangeso a changed object fails the request instead of mixing bytes from two versions. - Parsing.
mp4::parsestays synchronous CPU work, run on the bounded blocking pool over in-memory bytes. The async side reads only the top-level box headers and themoovpayload (already isolated infind_moov, bounded bymax_metadata_bytes) and hands the bytes to the parser. Themp4crate needs aRead + Seekinput, so the parser is given a virtual reader over the file layout in which onlyftyp/moovare backed by fetched bytes andmdatis skipped rather than fetched. WhetherMp4Reader::read_headercan be driven that way without touchingmdatmust be confirmed by a spike; if not, the fallback is the raw box walk already in src/mp4/parser.rs, which validatesmoovbefore the crate is used. - Mutation checks.
verify_unchanged(inode, mtime) is a local-only concept. Forhttpsources the equivalent is: the validator captured on the first range read must match on every later read, and themoovhash is still compared before and after the parse.
Asset registry behavior
registry.get(asset_id) -> Result<Arc<PackagedAsset>, RegistryError>:
- Validate the ID with the existing
validate_asset_idrules before any lookup. An invalid ID is404and never reaches the mapper, so path-like or oversized input cannot be forwarded. - Check the resolution cache. If a fresh entry exists, go to step 5.
- Resolve, coalesced. At most one in-flight resolve per asset ID; concurrent requests await the same result. If a stale entry exists, send its
versionasIf-None-Match. The shared resolve runs as its own task, so one waiting request being cancelled never cancels it for the others. - Store the answer.
Unchangedextends the existing entry’svalid_until.Resolvedreplaces it.NotFoundis cached as a negative entry fornegative_ttl_ms. - 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, readmoovasynchronously, runmp4::parse,segment::plan, and build init segments on the bounded blocking pool, then insert into the loaded cache. - Return
Arc<PackagedAsset>. In-flight requests keep theirArceven 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:
| Header | Required | Meaning |
|---|---|---|
Accept: application/json | Yes | |
Authorization: Bearer <token> | If configured | Static token from configuration or an environment variable |
If-None-Match: "<version>" | Optional | Sent on revalidation; the mapper may answer 304 |
X-Request-Id | Yes | Correlation 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"
}
}
| Field | Type | Required | Rules |
|---|---|---|---|
asset_id | string | Yes | Must equal the requested ID exactly |
version | string | Yes | Opaque, 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_seconds | integer | No | How long the answer may be reused. Clamped to [min_ttl_ms, max_ttl_ms]; defaults to default_ttl_ms when absent |
expires_at | RFC 3339 timestamp | No | Hard 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" | Yes | Unknown values are rejected |
location.path | string | For file | Relative, no .., no leading /, no NUL, at most 4096 bytes |
location.url | string | For http | Absolute; 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 status | Meaning | segmentor behavior |
|---|---|---|
404 | Asset does not exist | ResolveError::NotFound; client gets 404; negative-cached |
410 | Asset existed and was removed | Same as 404; also evicts any loaded copy |
401, 403 | This service is not authorized | ResolveError::Rejected; client gets 502; logged at error (operator misconfiguration) |
429 | Mapper is shedding load | Unavailable; honors Retry-After for backoff; client gets 503 |
5xx, timeout, connect error | Mapper unhealthy | Unavailable; retried within the deadline; client gets 503 |
2xx invalid body, ID mismatch, policy violation | Malformed answer | Rejected; client gets 502; never cached as valid |
Any other 4xx | Contract violation | Rejected; 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:
| Situation | Client response |
|---|---|
| Invalid asset ID syntax | 404; mapper not called |
| Mapper: not found or gone | 404 |
| Mapper: unavailable, no usable cached answer | 503 with Retry-After |
| Mapper: unauthorized, malformed, or policy-rejected answer | 502 with a generic body |
| Location kind not supported by this build | 502 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_ms | 503 |
| Overall request deadline exceeded | 408 (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
| Area | Change |
|---|---|
Config | assets: BTreeMap<..> becomes part of ResolverConfig::Static; add ResolverConfig::Http |
AppState | Replace assets: Arc<HashMap<..>> with Arc<AssetRegistry>; asset() becomes async |
MediaSource trait | Removed; replaced by the MediaSourceKind enum with async reads |
PackagedAsset::load | async; takes an opened MediaSourceKind instead of a path |
fmp4::write_media_segment | Async or test-only; the serving path uses the streaming pipeline |
SourceIdentity | Not filesystem-only: an enum with Local {..} and Remote { url_without_query, length, etag, last_modified } variants |
Error | Add resolver variants; map to the status table above |
| HTTP handlers | state.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.
filelocations receive the same treatment as TOML paths: reject absolute paths,.., and NUL, canonicalize beneathstorage.media_root, require a regular file, and re-check after canonicalization so symlinks cannot escape. - SSRF for
httplocations. A mapper-supplied URL makes the server issue requests, so it is allow-listed:- scheme
httpsby default;httponly withallow_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.
- scheme
- Mapper transport.
httpsrequired by default; the bearer token is read from an environment variable, redacted from logs, and sent only tobase_url’s origin. A mapper compromise can redirect assets to other allow-listed locations, but cannot make the server read paths outsidemedia_rootor 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_idmatches 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:
| Level | Event |
|---|---|
info | asset_loaded (existing, now also emitted for lazy loads, with `resolver = static |
debug | asset_resolve_started, asset_resolved (elapsed_ms, `cache = hit |
warn | asset_not_found, resolve_stale_served, resolve_retry |
error | resolve_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) andvod_asset_loads_total{outcome}vod_asset_load_duration_seconds(histogram) andvod_asset_load_queue_wait_secondsvod_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 andexpires_atprecedence; response parsing with unknown fields, missing fields, and mismatchedasset_id; SSRF checks for each rejected address class and scheme; status-to-ResolveErrormapping 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/410andexpires_at; location change producing a new loaded-cache key while an oldArckeeps streaming; weighted LRU eviction atmax_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,429withRetry-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
filelocations 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.
- Async media source: replace
MediaSourcewithMediaSourceKind(local variant only), makePackagedAsset::loadand the parser entry point async-aware, and keep every existing test green. This is a refactor with no behavior change. - Registry and static resolver: introduce
AssetRegistry,AssetResolver::Static, the asyncstate.asset()path, the weighted loaded cache, and single-flight loading, with an optionallazy = truefor the static resolver to exercise lazy loading. - Mapper client,
filelocations: implementHttpResolver, 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. httplocations:HttpMediaSource, the parse spike overmoov-only bytes,SourceIdentitygeneralization, and the SSRF controls. Until it ships,httplocations are rejected with the502outcome.- 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.
| Question | Decision | Consequence |
|---|---|---|
| Blocking or async media source | Async | MediaSource becomes MediaSourceKind (stage 1). ADR required |
| HTTP client dependency | Accepted | Candidate 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 mandatory | Yes | Simpler cache key (asset_id, version); mappers must produce a version and an ETag |
| Grace period for old versions after a location change | None | Old-version URLs return 404 once superseded; players recover by refetching the playlist. Interpreted from “we can break it” |
| Compatibility of pre-release APIs and URLs | Not preserved | Free 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
/v1path. Signed-URL lifetime for in-flight streamsis 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
| # | Finding | Severity | Decision |
|---|---|---|---|
| 1 | A 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. | High | Async streaming with per-read slots and an idle timeout |
| 2 | Init 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. | High | v is required and must match |
| 3 | The 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. | High | Use writeln!; assert the real value |
| 4 | Only SIGINT was handled, so a container SIGTERM killed the process without draining. | High | Graceful drain |
| 5 | CORS 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. | High | Configurable CORS |
| 6 | Every master playlist and MPD request walked every sample of each track to estimate bandwidth (up to millions). | Medium | Precompute at load |
| 7 | HTTP status was derived by matching error message strings such as "segment does not exist". | Medium | Typed Error::NotFound |
| 8 | HEAD on a media segment took a job slot and started the producer. | Medium | Answer from metadata only |
| 9 | If-None-Match matched only an exact single tag. No If-Range, no suffix ranges. | Medium | Conditional and range handling |
| 10 | Only one metric existed, and there was no request ID or readiness endpoint. | Medium | Observability |
| 11 | Each loaded asset keeps about 40 bytes per sample in memory with no total bound. | Medium | limits.max_index_bytes |
| 12 | Segment header construction (CPU proportional to sample count) ran on an async worker. | Medium | Run on the blocking pool |
| 13 | Audio segments were served as video/mp4; HLS BANDWIDTH was the average, not the peak; no release profile; no container image; no request cap. | Low | Correct content type, peak plus average bandwidth, [profile.release], Dockerfile, max_concurrent_requests |
| 16 | The 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. | High | Buffer the parser input (BufReader); the load now takes about 70 ms |
| 15 | axum::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. | Medium | In-process accept loop |
| 14 | stts/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. | High | Bound 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 return503; 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
HEADneither takes a slot nor starts a task. - Abort reasons are counted as
idle,client(disconnect), orerror.
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-Matchaccepts a comma-separated list, weak tags (W/"...", compared weakly), and*, across multiple header lines.Rangesupports a singlebytes=a-b,bytes=a-, and the suffix formbytes=-n.If-Rangethat does not equal the current strong ETag causes the range to be ignored and the full200to 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.
| Key | Default | Notes |
|---|---|---|
enabled | true | Set false when a proxy or CDN owns CORS |
allowed_origins | ["*"] | Exact scheme://host[:port] values, or ["*"]; cannot mix |
allowed_methods | ["GET", "HEAD"] | |
allowed_headers | range, if-none-match, if-range, x-request-id | ["*"] allowed |
exposed_headers | content-length, content-range, accept-ranges, etag, x-request-id | ["*"] allowed |
allow_credentials | false | Rejected with any wildcard |
max_age_seconds | 86400 | Preflight 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
/healthis liveness./readyreturns200until shutdown begins, then503.- During
server.shutdown_delay_msthe service keeps accepting connections so a load balancer can observe/readyand 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 andvod_http_connections_rejected_totalincrements. The permit travels with the connection task, so it is released however the connection ends. - Header-read timeout.
limits.header_read_timeout_msis 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
servestill bounds the wait. TCP_NODELAYis 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:
| Metric | Type |
|---|---|
vod_http_requests_total{route,status} | counter |
vod_http_request_duration_seconds{route} | histogram, time to response headers |
vod_http_requests_in_flight | gauge, cancellation-safe |
vod_http_response_bytes_total, vod_source_read_bytes_total | counters |
vod_http_requests_shed_total, vod_segment_queue_timeouts_total | counters |
vod_segment_stream_aborts_{idle,client,error}_total | counters |
vod_log_dropped_lines_total | counter |
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:
| Limit | Default | Behavior at the limit |
|---|---|---|
limits.max_concurrent_requests | 10,000 | 503 + Retry-After |
limits.response_idle_timeout_ms | 30,000 | Stream dropped, idle abort counted |
limits.max_index_bytes | 4 GiB | Startup fails with the measured size |
limits.max_connections | 10,000 | New connections closed at accept |
limits.header_read_timeout_ms | 10,000 | Connection closed |
server.shutdown_grace_ms | 30,000 | Remaining streams closed |
server.shutdown_delay_ms | 0 | Accept 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.rswas split intohttp/and the crate gained a library target after this design; see Code organization.
Open questions
- Should multi-range requests return
200with the full body, as RFC 9110 permits, instead of416? - Should the request-ID span also be attached to the segment streaming task so its
warn/errorlines 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
| Item | Status | Notes |
|---|---|---|
| 1. Edit lists | Implemented | src/mp4/edit.rs. One edit, optionally after one empty edit; tail trim; leading audio drop |
| 2. Accurate errors | Implemented | Fragmented input, unsupported codec, and edit-list shape errors name what was found |
| 3. Track selection, multiple audio | Implemented | Non-media tracks skipped and logged; audio tracks are audio-1, audio-2, and so on |
DASH SegmentTimeline t= | Implemented | |
| 4. Verbatim sample-entry pass-through | Implemented | stsd is copied byte for byte; pasp and colr now reach players |
| 5. In-tree container parser | Implemented | mp4/boxes.rs, tables.rs, codec.rs; the mp4 crate is a dev-dependency only, used to cross-check |
| 6. New codecs, audio-only | Implemented | HEVC, VP9, AV1, HE-AAC, AC-3, E-AC-3, Opus, FLAC, and audio-only assets; see the verification table below |
| 7. Fragmented MP4 input | Implemented | Designed and delivered in TDD 0005 |
Fixed ftyp brands | Implemented | iso6 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:
| Format | FFmpeg decodes the repackaged output | Codec string checked against | Chrome 153 (hls.js and dash.js) |
|---|---|---|---|
HEVC (hvc1) | Yes | Unit tests with reference strings (hvc1.1.6.L93.B0, hvc1.2.4.L120.B0); FFmpeg’s DASH muxer leaves HEVC blank | Cannot decode on this machine (isTypeSupported is false) |
| VP9 | Yes | Matches FFmpeg’s DASH muxer | Plays |
| AV1 | Yes | Matches FFmpeg’s DASH muxer | Plays |
| HE-AAC, HE-AACv2 | No real file: no HE-AAC encoder was available | Synthetic esds tests for object types 5 and 29 | Unverified; Chrome reports both codec strings as supported |
| AC-3, E-AC-3 | Yes | Matches FFmpeg’s DASH muxer | Cannot decode on this machine |
| Opus | Yes | Matches FFmpeg’s DASH muxer | Plays |
| FLAC | Yes | FFmpeg writes flac; segmentor writes fLaC, the sample entry tag. Chrome accepts both | Plays |
| Audio only, one track and two | Yes | Plays 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
mp4aalso carries MP3, which shows up as a non-AAC audio object type and is rejected by number. - MP3 in MP4 hides in an
mp4aentry. 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, andec-3are 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
dOpsand always decodes at 48 kHz; FLAC readsSTREAMINFO, 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. hev1is passed through, not rewritten. The codec string keeps the entry’s tag. Players that follow the standard (Chrome, Firefox) accept either; Apple requireshvc1, so anhev1file 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
.movwhosemp4aentry uses QuickTime’s sound description version 1 (extra fields,esdsinside awavebox, achanbox), 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 theesdsbox; 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 ofmoov. The same would happen behind a CDN on any upgrade that changes the bytes served for an unchanged file. The version now hashesmoovtogether withFORMAT_REVISIONinasset.rs, to be bumped whenever init layout, timeline mapping, or playlist format changes. paspwas dropped from every file, not only anamorphic ones. FFmpeg writes a 1:1paspinto 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
mp4crate 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
avcCandesdsare 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. SparseFileshrank toMetadata. With no crate wanting aRead + Seekview, the reader shim and theftypfetch 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
videorsoun, so the matrix row saying subtitles are rejected has been out of date since then. - Tracks are numbered in file order (
audio-1is 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 clearsedts, and a test checks the bytes. - Encoder handler names are noise. FFmpeg writes
SoundHandlerfor every audio track, so HLSNAMEisAudio {n}, plus the language in parentheses when the file names one, instead of thehdlrname. - 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
covrmetadata 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:
- Make ordinary files load: edit lists, track selection, multiple audio tracks, and accurate error messages.
- 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
mp4crate. This fixes a correctness bug (pixel aspect ratio is dropped today) and is the prerequisite for new codecs. - Add codecs: HEVC, VP9, AV1, HE-AAC, AC-3/E-AC-3, Opus, FLAC, and audio-only assets.
- 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.
| Input | Result | Evidence |
|---|---|---|
H.264 + AAC-LC, no edit list, moov first or last | Works | Fixtures; probe ok.mp4 |
| H.264 + AAC-LC as ffmpeg writes it by default | Rejected: “edit lists are not supported” | Probe default.mp4: elst [(144000, 1024, 1, 0)] on both tracks |
| Same, with no B-frames | Rejected: same error | Probe nob.mp4: the audio priming edit alone triggers it |
| Video-only H.264 | Works | Fixture h264-video-only.mp4 |
| 10-bit H.264 (High 10) | Loads, no warning | Probe h10.mp4. Most browsers cannot decode it |
Rotation metadata (tkhd matrix) | Loads | Probe rot.mp4. Whether players honor it through HLS/DASH is unverified |
| Two audio tracks | Rejected: “at most one audio track” | Probe twoaudio.mp4; planner.rs:46 |
HEVC (hvc1) | Rejected: “sample description is not supported” | Probe hevc0.mp4 |
| VP9, AV1 | Rejected: same error | Probes vp9.mp4, av1.mp4 |
| AC-3, Opus audio | Rejected: same error | Probes ac3.mp4, opus.mp4 |
| HE-AAC / HE-AACv2 | Rejected: “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 input | Rejected, 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 tracks | Expected to reject the whole file | Unverified. Common in phone and action-camera files; see Track selection |
Encrypted (encv/enca), external data reference, more than one stsd entry | Rejected on purpose | parser.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:
| Box | In source | In init segment |
|---|---|---|
pasp (pixel aspect ratio) | 1 | 0 |
btrt (bitrate) | 2 | 0 (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
CODECSstring 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
hev1tohvc1by 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
ftypbrands. These change where the design says so.
Design
Priorities
Ordered by how many real files each item unblocks, with correctness defects first:
| # | Item | Why this order | Phase |
|---|---|---|---|
| 1 | Edit lists (single edit, plus a lead-in empty edit) | Blocks ffmpeg’s default output, and therefore most files | 1 |
| 2 | Accurate errors, including fragmented MP4 | Cheap; today a user cannot tell what is wrong | 1 |
| 3 | Track selection: skip non-media tracks, multiple audio | Blocks phone and camera files | 1 |
| 4 | Verbatim sample-entry pass-through | Correctness defect (pasp); prerequisite for codecs | 2 |
| 5 | Own the container parsing | Removes the mp4 crate limits (.mov, stz2, new codecs) | 2 |
| 6 | New codecs, audio-only | Widens what can be served | 3 |
| 7 | Fragmented MP4 input | Large change; a smaller share of sources | 4 |
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
| Shape | Behavior |
|---|---|
No elst | Unchanged |
One non-empty edit, rate 1, media_time ≥ 0 | Supported |
| One empty edit followed by one non-empty edit, rate 1 | Supported |
More than these, rate other than 1, dwell edits, or media_time < -1 | Rejected: “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_durationends 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
SegmentTimelinemust state the first segment’s start witht=. Today it omitstand assumes zero (dash.rs), which is wrong once the first segment starts atO.
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 (
moofat top level, ormvexinmoov) in the raw preflight before themp4crate runs, and return “fragmented MP4 input is not supported”. Today the crate fails first with an unrelated message. - Every
Unsupportederror 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 descriptionhvc1is 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:
| Track | Default behavior |
|---|---|
vide/soun handler with a supported codec | Packaged |
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 codec | Rejected, 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=AUDIOper track in the sameGROUP-ID, withLANGUAGEfrommdhd,NAMEby position (Audio 1 (eng), because encoders write junk handler names), andDEFAULT=YESon the first only. - DASH: one audio
AdaptationSetper track, withlang. - URL keys:
video, thenaudio-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
CODECSin 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
moovbytes already held inSparseFile, and copy thetrakboxes for the chosen track withmvhd,tkhd,mdhd,hdlr,vmhd/smhd/nmhd, anddinfas they are. - Copy the
stsdbox byte for byte. This keepspasp,colr,mdcv,clli,btrt, and any codec configuration box the packager does not otherwise need to understand. - Write empty
stts,stsc,stsz,stcoboxes, as fMP4 requires, and themvex/trexboxes. - Zero the durations, as init.rs does now.
- Write a fixed
ftypinstead of copying the source’s: major brandiso6, compatible brandsiso6andmp41. Source brands such asqtormp42describe the original file, not this stream.cmfcis 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.
| Codec | Sample entry | Config box | CODECS string | Note |
|---|---|---|---|---|
| H.264 (have) | avc1 | avcC | avc1.PPCCLL | avc3 (in-band parameter sets) can be accepted when avcC is present |
| HEVC | hvc1, hev1 | hvcC | hvc1.<profile>.<compat>.<tier+level>.<constraints> | Apple requires hvc1; see Open questions |
| VP9 | vp09 | vpcC | vp09.PP.LL.DD | |
| AV1 | av01 | av1C | av01.P.LLT.DD | |
| AAC-LC (have) | mp4a | esds | mp4a.40.2 | |
| HE-AAC, HE-AACv2 | mp4a | esds (AudioSpecificConfig, object type 5 or 29) | mp4a.40.5, mp4a.40.29 | Explicit 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-3 | ac-3, ec-3 | dac3, dec3 | ac-3, ec-3 | |
| Opus | Opus | dOps | opus | |
| FLAC | fLaC | dfLa | fLaC |
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_mson 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
RESOLUTIONand list only the audio codec. DASH emits a single audioAdaptationSet. - 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
| Condition | Decision | Reason |
|---|---|---|
External data reference (dref not self-contained) | Permanent | It would let a file redirect reads outside the media root or allowed hosts |
Encrypted source (encv/enca, cenc/cbcs) | Deferred | Needs key-system, pssh, and senc handling; separate design |
More than one stsd entry | Deferred | Needs a per-sample description index and several entries in one init segment |
| Edit lists that cut, repeat, or change rate | Deferred | Would need concatenation-style planning |
| Multiple video tracks | Deferred | See Multiple audio tracks |
| Subtitles | Deferred | WebVTT/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 == 0and 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_tracksalready applies per track; add a fixed cap onelstentries, for example 16, before shape checking). - Timeline math uses checked arithmetic.
shift_tand rescaling to the track timescale usechecked_*, and a shift that overflowsu64rejects 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) andedit_list_applied(asset, track id,M_t,D_t, resultingshift_t) atinfoon load. - Add the failing fourcc, edit-list shape, or handler to the existing
asset_load_failedevent, 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
tfdtnever negative. - Leading-audio drop and trailing-trim rules, and every rejected shape.
- Pass-through: the
stsdbytes in the init segment equal the source’s, byte for byte. Golden test withpasp/colr. CODECSstring derivation per codec, compared with FFprobe’scodec_tag_stringand 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
| Phase | Contents | Exit criteria |
|---|---|---|
| 1 | Edit lists (item 1), accurate errors (2), track selection and multiple audio (3), DASH SegmentTimeline t=; new fixtures | ffmpeg-default files play with correct sync in the browser matrix. No regression on existing fixtures. Matrix table updated |
| 2 | Pass-through init writer (4), in-tree parser (5) | pasp/colr preserved; .mov and stz2 parse; the mp4 runtime dependency removed; fuzz target updated |
| 3 | Codecs and audio-only (6), one codec at a time in the order HEVC, HE-AAC, Opus, AC-3/E-AC-3, VP9, AV1, FLAC | Each codec passes decode round-trip and the protocol validators before it is listed as supported |
| 4 | Fragmented MP4 input (7) | Own design accepted first |
Breaking changes. Made deliberately, and none are shimmed:
- Track URL keys become
videoandaudio-N(wasaudio). The assetversionchanges, so every cached URL refreshes once. - The DASH
SegmentTimelinestates the first segment start witht=. - The init segment’s
ftypis fixed, and itsstsdis copied verbatim, sopasp,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_failedevent change to include what was found. - The
mp4crate 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
tfdtgreater than zero when the DASH manifest and HLS playlist both start there? If any player misbehaves, is carrying theelstin the init segment a better fallback for that player? hev1versushvc1. Settled: the entry’s tag is passed through and not rewritten (see the Phase 3 findings). Whether to warn when anhev1asset 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.
tkhdmatrix 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
tfdtput 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, sincesttsis 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
moofboxes were found to get the samev, because the version hashedmoovalone. That is enough for a progressive file, whosemoovholds every sample table, but a fragmented file’smoovis 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 hashesmoovand everymoof(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
trafin amoofto be measured, including other tracks’. Atrunwhose 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 withu32::MAXsamples in a track nobody asked for returns immediately. - FFmpeg’s
cmafflag 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.shadds 100 seconds to everytfdtof 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
sidxis 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 amoof; 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
sidxdescribes. Afirst_offsetthat 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/mdatpairs; all are found. Asidxcovering 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_fragmentsare not used. Asidxper fragment, as CMAF chunk writers emit, has one reference each and stays sequential. - Real
sidxboxes are messy: FFmpeg itself warns that itssidxis incorrect when tracks are written in separatemoofboxes. That is why verification and the fallback matter more than the speed-up. - A test corrupts random bytes of the
sidx400 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 ownmax_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
moovor a cut progressive file. - A cut
mdattakes themoofbefore it too, because thatmoofdescribes 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_droppedwith 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
moovand nothing else. A fragmented file’s index is inmoofboxes 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
moovdurations 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
moofor amoofper track, explicit and default-is-moof base offsets, alltrunfield 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
moovwith fragments. They are rejected. - Using
sidxormfrato avoid reading everymoof. 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:
| Limit | Default | Applies to |
|---|---|---|
max_fragments (new) | 20,000 | moof boxes in one file; about 11 hours at 2 s fragments |
max_metadata_bytes | 64 MiB | moov plus every moof, now summed |
max_samples_per_track | 2,000,000 | samples 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:
tfhdsupplies the track ID, optional defaults (duration, size, flags), and the base data offset. The base is the explicitbase_data_offsetif present, else the start of themoofwhendefault-base-is-moofis set, else the legacy rule: the start of themooffor the firsttraf, and the end of the previoustraf’s data after that. The legacy rule needs the previoustraf’s end whichever track it belongs to, so everytrafin amoofis measured.tfdtsets the decode time of the fragment. When absent, decoding continues from the end of the previous fragment of that track.trunlists samples. Each field (duration, size, flags, composition offset) falls back totfhd, then to thetrexdefaults inmvex. 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 thetrafended.- 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, andtkhddurations 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
sidxto find themoofboxes. Asidxlists 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 everymoofstill has to be read, and files without asidxneed 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 thesidxover 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
moofonly 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_loadedevent gainsmedia.fragmentsfor 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, amoofper track (CMAF), nodefault-base-is-moof(explicit base offsets), asidxpresent, and a start time of 100 seconds. - Unit tests for
trunfield combinations and defaults, first-sample flags, version 1 negative offsets, legacy base offsets across severaltrafs, missingtfdt, and every rejection (mixed samples,moofwithoutmvex, 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
moofbytes. - 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
moovand a partial last fragment. Should a truncated finalmoofbe 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:
- HLS I-frame playlists, for scrub previews and fast-forward. No mapper change, no decoding.
- Sidecar WebVTT subtitles, listed by the mapper and served by segmentor.
- 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.
EXTINFis the time from this keyframe to the next one, or to the end.BANDWIDTHis the peak I-frame bitrate: the largest keyframe in bits over the interval it represents.AVERAGE-BANDWIDTHuses the totals.CODECSis the video codec alone, andRESOLUTIONis 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
httpsubtitle 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 arelimits.max_subtitle_bytes,limits.max_subtitles_total_bytes, andlimits.max_subtitles. The version covers subtitle content; a mapper must still change its ownversionwhen 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 offsetO, 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 withWEBVTT. 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
Olater than the source’s clock (see TDD 0004), so a cue authored against the source would appear early byO. Cue timing lines are shifted byOwhen the asset loads. A track’s own delay is not part ofO, 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 withLANGUAGE,NAME,DEFAULT,AUTOSELECT, andFORCED, andSUBTITLES="subs"on the variant. Its playlistsubtitles/{language}/index.m3u8lists the whole file as a single segmentsubtitles/{language}/sub.vttwith anEXTINFof the asset duration, which is valid for VOD. - DASH. A text
AdaptationSetwithlang,mimeType="text/vtt", and aRepresentationwhoseBaseURLissubtitles/{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-INFper video rendition, sorted by bandwidth, each with its ownBANDWIDTH,AVERAGE-BANDWIDTH,CODECS, andRESOLUTION, all pointing at the shared audio group. - DASH. One video
AdaptationSetwith aRepresentationper rendition (id="video-{id}") andsegmentAlignment="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.
| Page | Covers |
|---|---|
| This page | Big picture, lifecycles, data model, concurrency, conventions |
| Media pipeline | source, mp4, media, segment, fmp4: from bytes on disk to fragments |
| Registry and resolvers | resolver, registry, remote source: asset lookup, caching, and loading |
| Protocols and assets | protocol (hls, dash, Presentation) and asset: playlists, manifests, and the loaded-asset object |
| HTTP server | http/: router, middleware, handlers, streaming, ranges, errors |
| Runtime support | config/, error, observability/ (logging, metrics), cli/, lib/main |
| Testing | Test layers, fixtures, fuzzing, make ci |
| Code organization | Review 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
maincallslib::run, which dispatches throughcli::runand parsesserve --configby hand.Config::loadreads and validates the TOML file, resolves the media root and every asset path to canonical absolute paths, and rejects anything outside the root.observability::logging::initinstalls the non-blockingtracingsubscriber.http::servecallsAppState::new, which builds the CORS layer, the resolver, the remote-media client, and the asset registry. Nothing is loaded yet.- With the static catalog,
preload()then loads every configured asset (bounded bylimits.max_startup_parses): open the source,mp4::parse,segment::plan, onefmp4::write_init_segmentper track, compute the version, and render all playlists from aPresentationview. Total index memory is checked againstlimits.max_index_bytes. A bad asset stops startup. With a mapper, assets load on their first request instead. - 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:
- 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, or500on failure). - For init and media routes, require
?v=to equal the asset version (404otherwise). - Check
If-None-Matchand answer304if it matches. - Produce the body:
- Playlist or manifest: clone precomputed
Bytes. - Init segment: slice the cached init
Bytes, honoringRange. - Media segment: build the header on the blocking pool, then stream header plus source ranges from an async task.
- Playlist or manifest: clone precomputed
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.
| Type | Defined in | Meaning |
|---|---|---|
MediaIndex | media/index.rs | Source identity, movie timescale and duration, and a Track per audio or video track |
Track | media/index.rs | Track ID, kind, timescale, codec configuration, and a Vec<Sample> |
Sample | media/index.rs | One encoded frame or audio packet: byte offset and size in the source, decode_time, duration, composition_offset, and is_sync |
SegmentPlan | segment/planner.rs | A list of Segments, each holding one TrackSegment per track: a half-open sample range plus decode time and duration |
PackagedAsset | asset.rs | Source, 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
| Work | Runs on | Bounded by |
|---|---|---|
| HTTP accept, routing, middleware, playlists | Tokio worker threads | max_concurrent_requests (soft) |
| Asset metadata fetch (local or remote) | Async tasks | limits.max_startup_parses load slots, remote max_inflight_reads |
| Asset assembly (planning, init segments, rendering) | Tokio blocking pool | The same load slots |
| Segment header construction | Tokio blocking pool (spawn_blocking) | Blocking pool size |
| Source reads for segments | Local: blocking pool, one read per chunk; remote: async ranged HTTP | limits.max_segment_jobs slots, held per read |
| Streaming a segment response | One async task per response | Two-item channel, response_idle_timeout_ms |
| Log writing | One dedicated thread (tracing-appender) | logging.buffer_capacity, lossy |
| Dropped-log monitor | One dedicated thread | Wakes 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
Errorvariants;HttpErrordecides the status. Error text goes to logs, never to clients (except the short messages for404). - 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_codeis forbidden, Clippyallandpedanticwarn and CI denies warnings,missing_docswarns, and rustfmt usesmax_width = 100. Runmake cibefore pushing. - Keep
config/lean. It stores plain strings for CORS and leaves parsing into HTTP types tohttp/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 to | Start in |
|---|---|
| Support another codec | mp4/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 cut | segment/planner.rs |
| Change fragment layout | fmp4/fragment.rs (build_moof, prepare_media_segment) |
| Add or change an HTTP route | http/router.rs (add a constant and list it in ROUTES, which also labels metrics) and a file in http/handlers/ |
| Add a config option | config/mod.rs (raw struct, validation, Config) or the matching file in config/, vod.example.toml, and the docs |
| Add a limit | LimitsConfig in config/limits.rs, its validation and default, then enforce it where the resource is allocated |
| Add a metric | observability/metrics.rs (field, recorder, render) |
| Add a source of media bytes | source/ (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:
mdatholds the encoded frames back to back.moovholds 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:
| Box | Tells us | Expanded into |
|---|---|---|
stsz | Size of each sample (or one constant size) | Sample.size |
stsc + stco/co64 | Which samples are in which chunk, and where each chunk starts | Sample.offset |
stts | Decode-time deltas, run-length encoded | Sample.decode_time, Sample.duration |
ctts | Composition (display) offsets for B-frames | Sample.composition_offset |
stss | Which video samples are keyframes | Sample.is_sync (absent means every sample is sync) |
stsd | Codec 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 checkedend().SourceIdentity: anOrigin, the length, and an optionalmoov_sha256.Origin::Localrecords canonical path, device, inode, and modification time;Origin::Remoterecords the URL without its query string and the validator reads are conditioned on. It identifies exactly which bytes were parsed.MediaSourceKind:LocalorHttp, with asyncread_rangeandverify_unchanged.
local.rs (LocalMediaSource) is the Linux file source.
opencanonicalizes the path, opens the file, and records device, inode, length, and mtime.read_rangechecks thatoffset + lengthdoes not overflow and stays within the file, then allocates and fills the buffer withread_exact_at. Positioned reads (pread) share no cursor, so concurrent requests can read oneFilewithout locking. It is synchronous;MediaSourceKind::read_rangeruns it on the blocking pool, one chunk per call.verify_unchangedre-reads metadata and fails if device, inode, length, or mtime changed sinceopen.
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:
- Size limit. Reject sources larger than
limits.max_source_bytes. 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 wholemoofand the header of itsmdatusually fit). Keepmoovwhole, and keep everymoofwhole with its offset. Asidxseen 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 fetchedlimits.metadata_concurrencyat a time, each verified by walking its own boxes to an exact end, and any mismatch (or asidxthat is hierarchical, has zero sizes, or lists more thanmax_fragments) leaves the walk sequential. A file that ends inside a fragment is refused with a message naminglimits.tolerate_truncated_tail, which, when set, drops the cutmooformdat(with themoofa cutmdatbelongs to) if at least one whole fragment precedes it. Reject impossible sizes, a missingmoov, more than 4,096 other top-level boxes, more thanlimits.max_fragmentsfragments ormdatboxes, andmoovplusmoofbytes beyondlimits.max_metadata_bytes. Nothing inmdatis ever read, whether the source is local or remote. Because every top-level box header is visited, a truncatedmdatis rejected here, before any table is expanded.
The remaining steps are synchronous CPU work in parse_metadata, run on the blocking pool:
validate_raw_moov. Walksmoovonce and decides, per track, whether it is packaged:mvexinmoovmeans fragmented input: itstrexdefaults are read, and the samples come from themoofboxes instead of frommoov;- tracks whose handler is neither
videnorsoun(timecode, timed metadata, subtitles) are skipped, and their tables are never read; dinf/drefmust contain exactly one self-containedurlentry (no external data references);stsdmust contain exactly one entry, and it must beavc1ormp4a;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.
- Hash
moovwith SHA-256. parse_trackper 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, orav1Cfor the codec string and dimensions;esdsfor AAC, whose audio object type must be LC, SBR, or SBR with parametric stereo (QuickTime’s versioned entries with awavebox included);dac3,dec3,dOps, ordfLafor AC-3, E-AC-3, Opus, and FLAC. Then the sample tables.- Sample tables (
mp4/tables.rs).stts,ctts(signed in version 1),stss,stsc,stszor the compactstz2, andstcoorco64are parsed with every entry count checked against its box before allocating.expand_samplesthen checks the sample count againstlimits.max_samples_per_trackand expands the tables throughsample_sizes,sample_offsets,sample_times, andcomposition_offsets.sample_timesandcomposition_offsetsbound each run-length entry against the sample count before expanding it, so a craftedsttsclaiming billions of samples fails immediately. Every sample’s byte range must end inside the source. Fragmented files (mp4/fragments.rs) take their samples from themoofboxes instead. Eachtraf’stfhdgives the track, the base offset (explicit, relative to themoof, or, in the legacy layout, where the previoustraf’s data ended), and defaults;tfdtgives the fragment’s decode time, continuing from the previous fragment when absent; eachtrunlists samples whose fields fall back to thetfhdand then thetrexdefaults. Atruncannot 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 inmoovas well as in fragments are refused. - 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 inMediaIndex::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 inmvhd,mdhd, andtkhdare usually zero. Tracks are numbered in file order: one video track, thenaudio-1,audio-2, and so on.
Back in async code:
- Mutation check.
verify_unchangedon the source, then re-read and re-hashmoov; if either differs, the source changed mid-parse and the result is discarded. For a remote objectverify_unchangedre-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 leastboundary_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.
ftypis fixed: major brandiso6, compatibleiso6andmp41. The source’s brands describe the source file, not this stream.- The
stsdbox (the sample entry) is copied byte for byte. That is what keepspasp(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-stylemp4aentry (sound description version 1,esdsinsidewave), which browsers refuse; it is rewritten in the ISO layout with the same channel count, sample rate, andesds. mvhd,tkhd, andmdhdare copied with their durations zeroed;hdlr,vmhd/smhd, anddinfare copied as they were.- The sample tables (
stts,stsc,stsz,stco) are written empty, and a hand-builtmvex/trextells 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: amoofbox followed by an 8-bytemdatheader,ranges: the source byte ranges to append (adjacent samples are merged bycoalesced_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):
| Field | Meaning |
|---|---|
source | The open MediaSourceKind (local file or remote object), used for payload reads |
index | The MediaIndex from mp4::parse |
plan | The SegmentPlan from segment::plan |
init_segments | One cached init segment (Bytes) per TrackKind |
limits | A copy of the limits, needed when preparing segments |
version | First 8 bytes of the moov SHA-256, as 16 hex characters |
rendered | Pre-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 toPresentation::track),init_segment(kind): look up a track or its init segment; a missing track isError::NotFound, which the HTTP layer turns into404.prepare_media_segment(kind, segment_index): find the track’sTrackSegmentin the plan and callfmp4::prepare_media_segmentwith sequence numbersegment_index + 1. An out-of-range index isError::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-renderedBytes.presentation(): returns thePresentationview 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, orError::NotFound.tracks()andversion().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:
CODECSjoins the video codec string with the default audio track’s, each fromCodecConfig::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=AUDIOrendition in groupaudio, with URIaudio-{n}/index.m3u8?v={version}. The first isDEFAULT=YES.NAMEisAudio {n}, followed by the language in parentheses when the file names one, andLANGUAGEis set frommdhd.mp4a.40.2is appended toCODECS, and the variant’s bandwidth counts the default rendition only. - One
#EXT-X-STREAM-INFcarriesBANDWIDTH(video peak plus audio peak),AVERAGE-BANDWIDTH(sums of averages),CODECS,RESOLUTION, and theAUDIOgroup, followed byvideo/index.m3u8?v={version}. - An audio-only asset has no video, so the variant has no
RESOLUTIONand 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):
mediaPresentationDurationis the longest track duration in seconds.- The video track becomes one
AdaptationSetwithRepresentation id="video"; each audio track becomes another, withid="audio-{n}", alangwhen known, anaudioSamplingRate, and anAudioChannelConfiguration. - Each representation has a
SegmentTemplatein that track’s timescale,startNumber="0",initialization="$RepresentationID$/init.mp4?v=...",media="$RepresentationID$/segments/$Number$/media.m4s?v=...", and aSegmentTimelinewith one<S d="..."/>per segment. The first entry also carriest, because a file with an edit list starts after zero. A timeline is used because real segment lengths vary with keyframe placement. Representation@bandwidthis 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.
| Type | Meaning |
|---|---|
AssetLocation | File(PathBuf) (beneath storage.media_root) or Http(Url) |
ResolvedAsset | Location, a required version, valid_until (when to revalidate), and optional hard_expiry (when the location itself dies) |
Resolution | Resolved(ResolvedAsset) or Unchanged { valid_until } (a mapper 304) |
ResolveError | NotFound, Unavailable (retry later), Rejected (the answer is invalid or forbidden) |
AssetResolver | An 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-0and requires206, aContent-Rangegiving the total length, and a validator: a strongETag, elseLast-Modified. A200(no range support), no validator, or only a weakETagis refused. - Read sends the range with
If-Range: <validator>. It requires206, an exact matchingContent-Rangeand total length, and, for anETag, the same tag on the response. A200or412means 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 tomax_retriesand surface asUpstreamUnavailable; everything else isUpstream. - 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) unlessallow_private_addressesis set. Filtering at connect time defeats DNS rebinding. Literal IPs skip resolution, soLocationPolicychecks 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
- Reject syntactically invalid IDs (they never reach a resolver).
lookuptakes two shortstd::sync::Mutexsections 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)
enter_flighttakes a per-assettokiomutex, creating it on demand. A request that finds it busy is a coalesced waiter (counted in metrics). TheFlightguard removes the map entry on drop when nobody else holds it, so the map cannot grow without bound.- After the lock the request looks up again; another request may have finished the work.
- 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 hithard_expiry). Resolvedreplaces 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 andPackagedAsset::update_locationpoints its remote source at the new URL (in-place rotation).Unchangedextendsvalid_until, never beyondhard_expiry.NotFoundrecords a negative entry and evicts loaded copies.Unavailableserves the previous answer if it is still insidestale_if_errorand 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 forerror_ttland returned as503.RejectedbecomesBadUpstream(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:
- Refresh ahead (
mapper.rs::interpret): for an answer withexpires_at,valid_untilis set tomax(remaining - refresh_margin, remaining / 2)from now, so the next request after that point revalidates while the old URL still works. - In-place rotation (
store_resolution): described above.HttpMediaSourcekeeps its URL in a mutex, soset_urltakes effect on the nextfetch. - Recovery on rejection:
HttpMediaSource::read_rangemaps origin401/403/410toError::LocationRejected. If the source has aLocationRefresher, it calls it once (refreshedguards against loops), swaps the URL, and retries. The registry suppliesAssetRefresher, which holds aWeak<AssetRegistry>(no reference cycle) and callsAssetRegistry::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, eitherFound { resolved, backoff_until }orMissing { until }, bounded bymax_cached_resolutions(expired entries are swept first). - Loaded cache (
cache.rs): a byte-weighted LRU keyed by(asset_id, version). Weight isPackagedAsset::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 anArcare 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
interpretorpolicy.rs, with a test inregistry/tests.rsthat feeds the bad answer through the mock mapper and asserts502. - New failure modes need a
RegistryErrormapping and a decision about whether to cache the failure. - Tests use real in-process servers (
testutil.rs):MockMapper(scriptable status, delay, body, token, health) andMockOrigin(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.
| File | Contents |
|---|---|
mod.rs | serve: bind, wire shutdown, grace timer |
server.rs | The accept loop: connection cap, header-read timeout, graceful drain |
state.rs | AppState::new (resolver, registry, clients, CORS), preload, job-slot acquisition |
router.rs | Route table and layer order |
middleware.rs | request_id, record_metrics, enforce_header_limit, shed_load |
handlers/ | health.rs (health, ready, metrics), playlist.rs, media.rs; parse_track in mod.rs |
stream.rs | StreamJob, the streaming task |
range.rs | ByteInterval, Range and If-Range parsing, 416 |
validators.rs | Entity tags and If-None-Match |
cors.rs | cors_layer |
error.rs | HttpError |
shutdown.rs | shutdown_signal |
tests.rs | Router-level tests |
The sections below follow the request path.
AppState
Cheaply cloneable shared state passed to every handler and middleware:
| Field | Purpose |
|---|---|
registry | Arc<AssetRegistry>: state.asset(id).await resolves and loads on demand (see Registry and resolvers) |
segment_jobs | Semaphore of limits.max_segment_jobs source-read slots |
request_slots | Semaphore of limits.max_concurrent_requests handler slots |
metrics | Arc<Metrics> |
ready | Readiness flag, cleared when shutdown starts |
cors | Optional prebuilt CORS layer |
| timeouts and sizes | Queue 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_id | Keeps a valid incoming X-Request-Id or generates one, and echoes it on the response |
TraceLayer | Creates an info-level request span carrying the request ID; logs request and response at debug |
record_metrics | Records 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_load | try_acquire a request slot or return 503 with Retry-After; /health, /ready, /metrics bypass it |
enforce_header_limit | Sums header name and value bytes; over max_request_header_bytes returns 431 |
TimeoutLayer | Returns 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:
- Look up asset, require
?v=, parse the track, and answer304if the ETag matches. - Prepare the segment on the blocking pool (
prepare_media_segment): this yields the header bytes, source ranges, and total length without reading payload. - Compute the requested byte interval from
RangeandIf-Range;416if unsatisfiable. - If the method is
HEAD, return headers with an empty body. No job slot, no task. - Otherwise acquire the first job slot (
503on queue timeout), spawn aStreamJob, and returnBody::from_streamover 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:
- Sends the part of the header (
moofplusmdatheader) that overlaps the requested interval. - 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. - For each chunk:
readacquires a job slot (the first read reuses the slot acquired by the handler), runsPackagedAsset::read_rangeon the blocking pool, releases the slot, thensendpushes the chunk into the channel underresponse_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_modifiedimplementsIf-None-Match(tag lists, weak tags,*).requested_range(headers, total, etag)parses onebytes=range, including suffixbytes=-n.If-Rangethat differs from the ETag means “ignore the range”. Multi-range and malformed ranges areErr(()), which handlers turn into416viarange_not_satisfiable.ByteInterval { start, end }is half-open, withoverlapused byStreamJob.media_response_buildersets200or206, content type,immutablecache control,Accept-Ranges,Content-Length,Content-Rangewhen 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
503atwarn(expected under load) and other5xxaterror,4xxatwarn, - always adds
Cache-Control: no-store, plusRetry-After: 1on503.
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.
| Section | Type | Notes |
|---|---|---|
[server] | ServerConfig | listen, shutdown_delay_ms (default 0), shutdown_grace_ms (default 30,000, must be greater than zero) |
[storage] | StorageConfig | media_root, resolved against the config file’s directory if relative |
[packaging] | PackagingConfig | segment_duration_ms (default 6000, must be greater than zero) |
[logging] | LoggingConfig | level, format (json or compact), buffer_capacity |
[limits] | LimitsConfig | Resource limits, all greater than zero (table below) |
[cors] | CorsConfig | See TDD 0003 |
[assets.<id>] | AssetConfig | path relative to media_root; static resolver only |
[resolver], [resolver.http] | ResolverSettings, MapperConfig | type = "static" (default) or "http"; the two forms are mutually exclusive with [assets] and are validated together |
[registry] | RegistryConfig | Resolution cache size, load queue timeout, and preload |
[remote_media] | RemoteMediaConfig | Host 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
| Limit | Default | Enforced in |
|---|---|---|
max_assets | 1,000 | Config::parse |
max_source_bytes | 1 TiB | mp4::parse |
max_metadata_bytes | 64 MiB | Metadata::fetch: moov plus every moof |
max_fragments | 20,000 | Metadata::fetch: moof boxes in one fragmented file |
metadata_concurrency | 16 | Metadata::fetch: fragments fetched at once through a sidx |
tolerate_truncated_tail | off | Metadata::fetch: drop a fragment the file is cut off inside, instead of refusing the file |
max_tracks | 8 | mp4::parse |
max_samples_per_track | 2,000,000 | mp4::parser::parse_samples |
max_samples_per_segment | 100,000 | segment::plan |
max_segment_bytes | 64 MiB | fmp4::prepare_media_segment |
max_segment_jobs | 2 per CPU, max 32 | AppState.segment_jobs |
segment_queue_timeout_ms | 2,000 | acquire_segment_permit |
stream_chunk_bytes | 256 KiB | StreamJob |
max_request_header_bytes | 16 KiB | enforce_header_limit |
request_timeout_ms | 30,000 | TimeoutLayer |
max_startup_parses | 4 | Concurrent asset loads in the registry (also bounds startup preload) |
max_concurrent_requests | 10,000 | shed_load |
response_idle_timeout_ms | 30,000 | StreamJob::send |
max_index_bytes | 4 GiB | AppState::load |
max_connections | 10,000 | http/server.rs accept loop |
header_read_timeout_ms | 10,000 | hyper 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.
| Variant | Meaning | HTTP mapping |
|---|---|---|
InvalidRange | A byte range fell outside a source | 500 |
InvalidMedia(String) | The file is malformed or inconsistent | 500 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 exist | 404 |
Upstream(String) | A remote origin or mapper misbehaved or was refused | 502 |
UpstreamUnavailable(String) | A remote origin timed out or is overloaded (retryable) | 503 |
Io, Mp4, Toml | Wrapped library errors | 500 |
Configuration(String) | Invalid configuration | Startup failure |
Logging(String) | Logger initialization failure | Startup 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)andbuffered_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
/metricsthroughdropped_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 bytests/cli.rs); serve --config <file>(cli/serve.rs):Config::load,logging::init, thenhttp::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
| Layer | Where | What it proves |
|---|---|---|
| Unit tests | #[cfg(test)] modules beside the code | Parsing, planning, rendering, config validation, range logic |
| Registry and mapper tests | src/registry/tests.rs with src/testutil.rs | Resolution, caching, single flight, revalidation, stale-if-error, mapper failures, and remote media, against in-process mapper and origin servers |
| Router tests | src/http/tests.rs | Real routing, middleware, headers, and streaming, through tower::ServiceExt::oneshot (no socket) |
| Decode test | http::tests::ffmpeg_decodes_hls_and_dash_presentations | FFmpeg plays the HLS and DASH output over a real TCP server |
| Process tests | tests/cli.rs, tests/package.rs | The compiled binary runs; package output is well-formed and deterministic |
| Conformance | tests/conformance.rs | Playlist grammar, timelines, fMP4 box arithmetic, HLS/DASH byte identity, and decoding, against the running binary |
| Performance | benches/budgets.rs | The TDD 0001 latency, memory, and concurrency budgets (make bench) |
| Fuzzing | fuzz/fuzz_targets/media_pipeline.rs | Parsing, 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:
| File | Purpose |
|---|---|
h264-aac.mp4 | Main fixture: 320x180, 30 fps, 3 seconds, keyframe every second, two B-frames, 48 kHz AAC |
h264-aac.ffprobe.json | FFprobe packet dump: the ground truth the index is compared against |
h264-aac-moov-last.mp4 | moov after mdat |
h264-aac-edit-list.mp4 | Edit lists written by -c copy remuxing |
h264-aac-default-edits.mp4 | FFmpeg’s default output: an edit list per track for B-frame delay and AAC priming |
h264-aac-audio-delay.mp4 | Audio starts half a second late, so its track has a leading empty edit |
h264-aac-two-audio.mp4 | Two audio tracks, tagged eng and spa |
h264-aac-timecode.mp4 | An extra tmcd track that must be skipped |
h264-aac-anamorphic.mp4 | Non-square pixels and tagged colour: the sample entry carries pasp and colr |
h264-aac-quicktime.mov | QuickTime: versioned mp4a entries with a wave box, and a qt brand |
hevc-aac.mp4 | HEVC video (hvc1) |
vp9-opus.mp4, av1-aac.mp4 | VP9 with Opus, and AV1 with AAC |
h264-ac3.mp4, h264-eac3.mp4, h264-flac.mp4 | AC-3, E-AC-3, and FLAC audio |
aac-only.m4a, aac-two-tracks-only.m4a | Audio only, with FFmpeg’s default edit list, and two tagged tracks |
h264-mp3.mp4 | MP3 in MP4: an mp4a entry that must be rejected by its audio object type |
h264-aac-fragmented*.mp4 | The 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.mp4 | No audio track |
h264-aac-44100-stereo.mp4 | Different audio parameters |
h264-variable-timing.mp4 | Two 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.
MockMapperandMockOrigininsrc/testutil.rsare 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_packetschecks 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_samplecompares the in-tree parser with themp4crate (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
stsdbytes equal the source’s, sopasp,colr, andavcCsurvive; 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_pipelinecorrupts a few random bytes of each fixture’smoovthousands 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 everycargo test; the fuzz target below is the deeper version. -
Byte mutation instead of new fixtures: tests copy
moovfrom the fixture and patch a few bytes to make an invalid input (find_typelocates a box by name). Seerejects_multiple_sample_descriptionsandrejects_run_length_entries_that_claim_more_samples_than_stsz. -
State injection for limits: HTTP tests build an
AppStatewith small limits, then hold semaphores (segment_jobs,request_slots) to force503paths, or shrinkstream_chunk_bytesandresponse_idle_timeout_msto 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.rsruns 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
- Put unit tests in the module’s
testsblock using the fixtures above. - For a new rejection rule, mutate fixture bytes rather than adding a binary.
- For a new route or header behavior, add a router test with
oneshotinhttp/tests.rs. - Anything that changes fragment or playlist bytes should also keep the FFmpeg decode test and
tests/package.rsgreen.
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
| Step | State |
|---|---|
1. lib.rs plus a thin main.rs; fuzz depends on the library | Done. 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 view | Done. 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:
ETagandIf-None-Matchhandling lives inhttp/validators.rs, separate fromrange.rs.- The router tests are one
http/tests.rsfile rather than spread across modules, because they drive the whole router. - Route table.
http/router.rsdefines each route template once as a constant and buildsROUTESfrom 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 forunmatchedandother. - Renderer independence.
PackagedAsset::loadbuilds aPresentationfrom 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 fromPackagedAssetintoPresentation. - Renderer tests build a
Presentationfrom the fixture directly and no longer load a full asset.
Assessment
What works well:
- The media pipeline is cleanly layered.
source,media,mp4,segment, andfmp4have 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:
http.rsmixes 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.- The crate is binary-only. There is no
lib.rs. Consequences:- the fuzz target must include source files with
#[path], which is brittle (it forcesconfig.rsto 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.
- the fuzz target must include source files with
main.rsholds application code: thepackagecommand is real logic (about 80 lines) living in the entry point.- Cross-cutting infrastructure is at the top level.
metrics.rsandlogging.rsare observability, andmetrics.rshard-codes the HTTP route list, coupling it tohttp.rs. hls/dashandassetreference each other, a small cycle:assetcalls the renderers, and the renderers readPackagedAsset.- 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, andasset.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.
Recommended structure
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.rsplus a thinmain.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 intorange.rs), and the large router test module becomes atests/http_*.rsintegration 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;assetcalls them;protocolno longer depends onasset’s internals.observability/groups logging and metrics, and metrics stops owning the route table.- Visibility. Make the library’s public surface deliberate:
pubfor 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.
- Add
lib.rs, makemain.rsthin, point fuzz at the library. (Unblocks benchmarks.) - Move the
packagecommand intocli/. - Split
http.rsintohttp/(start with pure pieces:range.rs,error.rs,cors.rs,shutdown.rs; thenstream.rs; then handlers and middleware). - Split
config.rsintoconfig/. - Move
hls/dashtoprotocol/and pass a read-only view. - Group
logging/metricsunderobservability/.
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:
- Resolve a local path, remote HTTP source, or mapped media-set description.
- Read and parse MP4 metadata, including sample tables and codec configuration.
- Cache metadata so segment requests do not repeatedly parse the source.
- Select tracks and calculate segment boundaries, optionally aligning them to keyframes.
- Generate HLS, DASH, MSS, or HDS manifests with protocol-specific code.
- Generate segment container headers with its own HLS, DASH, MP4, and MPEG-TS writers.
- 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 throughlibswscale; - volume-map generation decodes audio with
libavcodec; - playback-rate, gain, and mixing filters use libraries such as
libavcodecandlibavfilter; - 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
- Kaltura nginx-vod-module
- nginx-vod-module feature and dependency documentation
- nginx-vod-module performance recommendations
Architectural decision records
ADRs record important architectural choices and the reasoning available when each choice was made.
Decisions
| ID | Title | Status |
|---|---|---|
| 0001 | Use fragmented MP4 as the initial media segment format | Accepted |
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
- Copy template.md to the next zero-padded number.
- Describe the context and competing constraints, not only the selected technology.
- Record meaningful alternatives and consequences.
- Merge an ADR as
ProposedorAccepted. - 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
moofconstruction, 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.
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.