From af5ec95ae2f5e625665195b2f76d64171277bc83 Mon Sep 17 00:00:00 2001 From: includeamin Date: Mon, 21 Sep 2026 18:12:35 +0200 Subject: [PATCH 1/7] docs(readme): move usage, demo and CLI details into the handbook Signed-off-by: includeamin --- README.md | 113 +++++------------------------------------------- docs/SUMMARY.md | 1 + docs/usage.md | 50 +++++++++++++++++++++ 3 files changed, 62 insertions(+), 102 deletions(-) create mode 100644 docs/usage.md diff --git a/README.md b/README.md index 50cda78..311f682 100644 --- a/README.md +++ b/README.md @@ -112,116 +112,25 @@ Progressive and fragmented MP4, M4A, and QuickTime `.mov` files, with `moov` fir 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](docs/technical-design/0004-broader-mp4-input-support.md) for what was verified where. -## Using it - -Start the example service with: - -```sh -make serve -``` - -The example asset is available at `http://127.0.0.1:3000/hls/sample/master.m3u8`. Configuration is loaded from `vod.example.toml`; asset IDs map to files beneath one canonical media root, and paths cannot escape that root. - -Logging is configured in the same file: - -```toml -[logging] -level = "info" # trace, debug, info, warn, or error -format = "json" # json or compact -buffer_capacity = 8192 -``` - -Logs are written by a dedicated worker thread through a bounded, lossy queue. If the logger cannot keep up, log lines are dropped instead of blocking media requests. Request and segment timing events use `debug`, so the default `info` level records lifecycle and asset-loading events without logging every media request. - -Each configured asset exposes: - -```text -/hls/{asset}/master.m3u8 -/hls/{asset}/video/index.m3u8 -/hls/{asset}/audio-{n}/index.m3u8 (one per audio track, numbered from 1) -/hls/{asset}/{track}/init.mp4 -/hls/{asset}/{track}/segments/{index}/media.m4s -/dash/{asset}/manifest.mpd -/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 read through a bounded backpressured stream instead of buffering the complete segment in each HTTP request. - -Assets can also be resolved on demand from an external mapper service, including media held on remote HTTP origins; see the [Mapper API reference](docs/mapper-api.md) and [docs/operations.md](docs/operations.md). - -CORS, shutdown behavior, concurrency limits, and metrics are configurable in the same file; see [docs/operations.md](docs/operations.md) for production guidance. - -## 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. It also shows player-side stats such as buffer, bandwidth, and dropped frames. - -```sh -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 in the config, as in `vod.example.toml`. - -## Packaging from the command line - -The packager parses a local MP4, creates keyframe-aligned segment plans, and writes separate fragmented MP4 audio and video tracks: - -```sh -cargo run -- package \ - --input tests/fixtures/h264-aac.mp4 \ - --output target/package-test -``` - -Regenerate the synthetic parser fixture and its FFprobe packet manifest with `make fixtures`. FFmpeg is used only for fixture generation and output validation, not by the application. - -## Releases - -Merges to `main` are tagged automatically with a semantic version derived from [Conventional Commits](https://www.conventionalcommits.org), so use `feat:`, `fix:`, or `type(scope)!:` in commit and pull request titles. Publish a release, with a generated changelog and a `latest` or `preview` flag, from the **Release** workflow. See [docs/releasing.md](docs/releasing.md). - ## Documentation -The project handbook uses the same `mdBook` interface as the Rust Book. Install the pinned documentation tool and serve the book with live reload: +The handbook, built with mdBook, covers everything beyond this page: -```sh -make install-doc-tools -make book-serve -``` +- [Using segmentor](docs/usage.md): endpoints, the web player demo, the packaging command +- [Deploying](docs/deployment.md) and [Operating the origin](docs/operations.md) +- [Mapper API](docs/mapper-api.md), [architecture](docs/architecture.md), and the + [technical designs](docs/technical-design/README.md) +- [Releasing](docs/releasing.md), including how versions and tags are made -Build the complete static site, including the `rustdoc` API reference, with `make site`. The book starts at `target/book/index.html`, and its API Reference chapter links to the generated Rust documentation under `target/book/api/`. - -See [docs/README.md](docs/README.md) for the documentation layout and authoring workflow. +Build it locally with `make book-serve` (see [docs/README.md](docs/README.md)). ## Contributing and security Contributions are welcome; start with [CONTRIBUTING.md](CONTRIBUTING.md), which covers building, -testing, design documents, and the sign-off every commit needs. Files that will not load or play -are the most useful reports there are: there is an issue form for them. Please report security -problems privately, as described in [SECURITY.md](SECURITY.md). Everyone taking part is expected to -follow the [Code of Conduct](CODE_OF_CONDUCT.md). - -## Development - -The repository uses stable Rust with `rustfmt` and Clippy. Run the complete local validation suite with: - -```sh -make ci -``` - -Run `make help` to list the individual build, check, format, lint, test, and documentation targets. - -`make conformance` runs the black-box HLS and DASH conformance suite, and `make bench` measures the performance budgets (see [docs/benchmarks.md](docs/benchmarks.md)). - -Compile the media-pipeline fuzz target on stable with `make fuzz-check`. To run a fuzz campaign, install the separate nightly tooling. `make fuzz` copies the generated MP4 fixtures into an ignored, writable corpus before starting libFuzzer: - -```sh -rustup toolchain install nightly -cargo install cargo-fuzz --locked -make fuzz -``` +testing, and the sign-off every commit needs. Files that will not load or play are the most useful +reports there are: there is an issue form for them. Report security problems privately, as +described in [SECURITY.md](SECURITY.md). Everyone taking part is expected to follow the +[Code of Conduct](CODE_OF_CONDUCT.md). ## License diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b2c4299..53da236 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -5,6 +5,7 @@ # Design - [Architecture](architecture.md) +- [Using segmentor](usage.md) - [Deploying](deployment.md) - [Operating the origin](operations.md) - [Performance budgets](benchmarks.md) diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..2ea7e15 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,50 @@ +# Using segmentor + +## Run the example + +```sh +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](mapper-api.md), including media on remote HTTP origins. + +## Endpoints + +Each asset exposes: + +```text +/hls/{asset}/master.m3u8 +/hls/{asset}/video/index.m3u8 +/hls/{asset}/audio-{n}/index.m3u8 (one per audio track, numbered from 1) +/hls/{asset}/{track}/init.mp4 +/hls/{asset}/{track}/segments/{index}/media.m4s +/dash/{asset}/manifest.mpd +/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](operations.md). + +## 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. + +```sh +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: + +```sh +cargo run -- package --input tests/fixtures/h264-aac.mp4 --output target/package-test +``` From d733b46700721e5fa36c723a38a824acb7cb11af Mon Sep 17 00:00:00 2001 From: includeamin Date: Mon, 21 Sep 2026 18:15:58 +0200 Subject: [PATCH 2/7] docs(readme): move the supported-input reference into the handbook Signed-off-by: includeamin --- README.md | 41 ++++++++++++----------------------------- docs/SUMMARY.md | 1 + docs/supported-input.md | 13 +++++++++++++ 3 files changed, 26 insertions(+), 29 deletions(-) create mode 100644 docs/supported-input.md diff --git a/README.md b/README.md index 311f682..b1e8c91 100644 --- a/README.md +++ b/README.md @@ -22,18 +22,17 @@ proportional to the segment asked for, not to the length of the video. - **Packages on demand.** Playlists and manifests are built when an asset loads and segments when they are requested, from a sample index kept in memory. There is no packaging step and no output to store. -- **Reads what encoders produce.** Progressive and fragmented MP4, M4A, and QuickTime files; H.264, - HEVC, VP9, and AV1; AAC, HE-AAC, AC-3, E-AC-3, Opus, and FLAC; edit lists, several audio tracks, - and audio-only files. See [Supported input](#supported-input). +- **Reads what encoders produce.** Progressive and fragmented MP4, M4A, and QuickTime files with + H.264, HEVC, VP9, or AV1 video and AAC, AC-3, E-AC-3, Opus, or FLAC audio. See + [supported input](docs/supported-input.md). - **Finds media through a mapper.** A mapper service answers "where is asset X?" with a file or an HTTP location, so the catalog lives wherever you already keep it. Remote origins are read with ranged requests and the server never downloads a whole file. - **Is bounded by construction.** Every length read from a file or a mapper is checked before it is allocated, and the limits are configuration. `unsafe` code is forbidden. The parser is run against corrupted input on every test run, and has a fuzz target. -- **Is built to be operated.** Prometheus metrics, request IDs, readiness and liveness endpoints, - load shedding, graceful shutdown, immutable content-versioned URLs for CDNs, and byte-range and - conditional requests. +- **Is built to be operated.** Prometheus metrics, health endpoints, load shedding, graceful + shutdown, and immutable content-versioned URLs for CDNs. ## What it does not do @@ -72,16 +71,15 @@ Then play it: ffplay http://127.0.0.1:3000/hls/sample/master.m3u8 ``` -or, in a second terminal, `make demo` and open for a player with live -server metrics next to it. `make help` lists everything else. +`make demo` starts a web player with live server metrics, and `make help` lists everything else; see +[Using segmentor](docs/usage.md). ## Install - **Release binaries.** Each [GitHub release](https://github.com/includeamin/segmentor/releases) attaches Linux binaries for x86-64 and arm64 with checksums and build attestations. [`install.sh`](install.sh) downloads one, verifies its checksum, and copies it into place - (`curl -fsSL https://raw.githubusercontent.com/includeamin/segmentor/main/install.sh | sh`; read - it first, it is short). + ). - **Container image.** `ghcr.io/includeamin/segmentor`, for the same architectures: ```sh @@ -93,31 +91,16 @@ server metrics next to it. `make help` lists everything else. - **From source.** `cargo install --git https://github.com/includeamin/segmentor`, which needs a recent stable Rust toolchain and a C compiler. -[Verifying a download](docs/releasing.md#verifying-a-release) explains how to check the -attestations and the image signature. [Deploying](docs/deployment.md) has a Docker Compose file, a -Kubernetes manifest, and a hardened systemd unit, and says what to put in front of it: segmentor has -no TLS or authentication of its own. - -## 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](docs/technical-design/0004-broader-mp4-input-support.md) for what was verified where. +See [Deploying](docs/deployment.md) for Compose, Kubernetes and systemd files, and +[Verifying a release](docs/releasing.md#verifying-a-release) for checking downloads. ## Documentation The handbook, built with mdBook, covers everything beyond this page: - [Using segmentor](docs/usage.md): endpoints, the web player demo, the packaging command -- [Deploying](docs/deployment.md) and [Operating the origin](docs/operations.md) +- [Supported input](docs/supported-input.md), [Deploying](docs/deployment.md), and + [Operating the origin](docs/operations.md) - [Mapper API](docs/mapper-api.md), [architecture](docs/architecture.md), and the [technical designs](docs/technical-design/README.md) - [Releasing](docs/releasing.md), including how versions and tags are made diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 53da236..263e83a 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -6,6 +6,7 @@ - [Architecture](architecture.md) - [Using segmentor](usage.md) +- [Supported input](supported-input.md) - [Deploying](deployment.md) - [Operating the origin](operations.md) - [Performance budgets](benchmarks.md) diff --git a/docs/supported-input.md b/docs/supported-input.md new file mode 100644 index 0000000..a78930f --- /dev/null +++ b/docs/supported-input.md @@ -0,0 +1,13 @@ +# 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](technical-design/0004-broader-mp4-input-support.md) for what was verified where. From df828cde12b807cb1e65ffcf3b3c860229252806 Mon Sep 17 00:00:00 2001 From: includeamin Date: Mon, 21 Sep 2026 18:22:32 +0200 Subject: [PATCH 3/7] feat(hls): serve I-frame playlists for scrubbing Signed-off-by: includeamin --- README.md | 4 +- ...006-trick-play-subtitles-and-renditions.md | 2 + docs/usage.md | 2 + src/asset.rs | 44 ++++- src/http/handlers/media.rs | 61 ++++++- src/http/handlers/mod.rs | 4 +- src/http/handlers/playlist.rs | 13 ++ src/http/router.rs | 12 +- src/protocol/hls.rs | 161 ++++++++++++++++++ tests/conformance.rs | 120 +++++++++++++ 10 files changed, 406 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b1e8c91..204e0ca 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ proportional to the segment asked for, not to the length of the video. ## What it does not do - **No transcoding.** The codecs must already suit the protocol, and there is no bitrate ladder - unless you supply the renditions. Adaptive renditions, WebVTT subtitles, and HLS I-frame - playlists are [planned](docs/technical-design/0006-trick-play-subtitles-and-renditions.md). + unless you supply the renditions. Adaptive renditions and WebVTT subtitles are + [planned](docs/technical-design/0006-trick-play-subtitles-and-renditions.md). - **No DRM, no live streaming, no MPEG-TS output.** Segments are fragmented MP4. DRM is planned after the features above. - **No TLS or authentication.** Run it behind a reverse proxy or CDN that provides both; see diff --git a/docs/technical-design/0006-trick-play-subtitles-and-renditions.md b/docs/technical-design/0006-trick-play-subtitles-and-renditions.md index a3a1ae4..6f541e2 100644 --- a/docs/technical-design/0006-trick-play-subtitles-and-renditions.md +++ b/docs/technical-design/0006-trick-play-subtitles-and-renditions.md @@ -39,6 +39,8 @@ An asset is one MP4 today: one video track, some audio tracks, and a single vide ## 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. diff --git a/docs/usage.md b/docs/usage.md index 2ea7e15..14c0e5b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -16,6 +16,8 @@ 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 /dash/{asset}/manifest.mpd diff --git a/src/asset.rs b/src/asset.rs index d3f8d02..2d8488c 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -8,7 +8,7 @@ use crate::error::{Error, Result}; use crate::media::{MediaIndex, Sample, Track, TrackKey}; use crate::mp4::ParsedMedia; use crate::protocol::{Presentation, dash, hls}; -use crate::segment::SegmentPlan; +use crate::segment::{SegmentPlan, TrackSegment}; use crate::source::{ByteRange, LocalMediaSource, MediaSourceKind}; use crate::{fmp4, mp4, segment}; use std::sync::Arc; @@ -29,6 +29,7 @@ pub(crate) struct PackagedAsset { struct RenderedManifests { hls_master: Bytes, hls_media: HashMap, + hls_iframes: Option, dash: Bytes, } @@ -120,6 +121,44 @@ impl PackagedAsset { .ok_or(Error::NotFound("track does not exist")) } + /// The video track's I-frame playlist, absent for an audio-only asset. + pub(crate) fn hls_iframe_playlist(&self) -> Result { + self.rendered + .hls_iframes + .clone() + .ok_or(Error::NotFound("asset has no video track")) + } + + /// The fragment holding only the `frame_index`th keyframe of the video track. + pub(crate) fn prepare_iframe(&self, frame_index: u32) -> Result { + let presentation = self.presentation(); + let track = presentation + .video() + .ok_or(Error::NotFound("asset has no video track"))?; + let position = track + .samples + .iter() + .enumerate() + .filter(|(_, sample)| sample.is_sync) + .nth(usize::try_from(frame_index).map_err(|_| { + Error::InvalidMedia("keyframe index does not fit in memory".to_owned()) + })?) + .map(|(position, _)| position) + .ok_or(Error::NotFound("keyframe does not exist"))?; + let sample = track.samples[position]; + let segment = TrackSegment { + track_id: track.id, + first_sample: position, + end_sample: position + 1, + decode_time: sample.decode_time, + duration: u64::from(sample.duration), + }; + let sequence_number = frame_index + .checked_add(1) + .ok_or_else(|| Error::InvalidMedia("sequence number overflow".to_owned()))?; + fmp4::prepare_media_segment(track, segment, sequence_number, &self.limits) + } + pub(crate) fn dash_manifest(&self) -> Bytes { self.rendered.dash.clone() } @@ -244,6 +283,7 @@ impl RenderedManifests { Ok(Self { hls_master: Bytes::from(hls::master_playlist(presentation)?), hls_media, + hls_iframes: hls::iframe_playlist(presentation)?.map(Bytes::from), dash: Bytes::from(dash::manifest(presentation)?), }) } @@ -255,7 +295,7 @@ impl RenderedManifests { /// Media URLs are cached as immutable by browsers and CDNs, so a new build that answers an old /// URL with different bytes would be served stale content until the cache expires. Mixing the /// revision into the version gives such a build new URLs instead. -const FORMAT_REVISION: u32 = 1; +const FORMAT_REVISION: u32 = 2; /// The `v` value in media URLs: a hash of everything the index was built from (`moov`, and every /// `moof` of a fragmented file) and [`FORMAT_REVISION`]. diff --git a/src/http/handlers/media.rs b/src/http/handlers/media.rs index bb42be2..e70921d 100644 --- a/src/http/handlers/media.rs +++ b/src/http/handlers/media.rs @@ -14,6 +14,7 @@ use tokio_stream::wrappers::ReceiverStream; use super::parse_track; use crate::asset::PackagedAsset; +use crate::fmp4::PreparedSegment; use crate::http::error::{HttpError, HttpResult}; use crate::http::range::{ByteInterval, range_not_satisfiable, requested_range}; use crate::http::state::AppState; @@ -70,7 +71,53 @@ pub(crate) async fn media_segment( version.require(&asset)?; let key = parse_track(&track)?; let etag = entity_tag(&asset, &format!("{track}-segment-{segment_index}")); - if not_modified(&headers, &etag) { + serve_segment( + &state, + &method, + &headers, + asset, + etag, + key.kind, + move |asset| asset.prepare_media_segment(key, segment_index), + ) + .await +} + +/// One keyframe of the video track as a fragment of its own, for HLS I-frame playlists. +pub(crate) async fn iframe_segment( + State(state): State, + method: Method, + Path((asset_id, frame_index)): Path<(String, u32)>, + Query(version): Query, + headers: HeaderMap, +) -> HttpResult { + let asset = state.asset(&asset_id).await?; + version.require(&asset)?; + let etag = entity_tag(&asset, &format!("iframe-{frame_index}")); + serve_segment( + &state, + &method, + &headers, + asset, + etag, + TrackKind::Video, + move |asset| asset.prepare_iframe(frame_index), + ) + .await +} + +/// Answers a request for a generated fragment: conditional, ranged, and streamed from the source +/// through a bounded queue. +async fn serve_segment( + state: &AppState, + method: &Method, + headers: &HeaderMap, + asset: Arc, + etag: HeaderValue, + kind: TrackKind, + prepare: impl FnOnce(&PackagedAsset) -> crate::error::Result + Send + 'static, +) -> HttpResult { + if not_modified(headers, &etag) { return not_modified_response(etag, "public, max-age=31536000, immutable"); } let started = Instant::now(); @@ -78,12 +125,12 @@ pub(crate) async fn media_segment( // the blocking pool rather than an async worker. let prepared = { let asset = Arc::clone(&asset); - tokio::task::spawn_blocking(move || asset.prepare_media_segment(key, segment_index)) + tokio::task::spawn_blocking(move || prepare(&asset)) .await .map_err(|error| HttpError::internal(error.to_string()))?? }; let total_length = prepared.content_length; - let requested_interval = match requested_range(&headers, total_length, &etag) { + let requested_interval = match requested_range(headers, total_length, &etag) { Ok(range) => range.unwrap_or(ByteInterval { start: 0, end: total_length, @@ -97,7 +144,7 @@ pub(crate) async fn media_segment( total_length, requested_interval, etag, - segment_content_type(key.kind), + segment_content_type(kind), ) .body(Body::empty()) .map_err(|error| HttpError::internal(error.to_string())); @@ -122,9 +169,7 @@ pub(crate) async fn media_segment( ); tracing::debug!( event = "media_segment_generated", - asset.id = %asset_id, - media.track = %track, - media.segment = segment_index, + media.kind = ?kind, response.bytes = content_length, elapsed_us = started.elapsed().as_micros(), ); @@ -132,7 +177,7 @@ pub(crate) async fn media_segment( total_length, requested_interval, etag, - segment_content_type(key.kind), + segment_content_type(kind), ) .body(Body::from_stream(ReceiverStream::new(receiver))) .map_err(|error| HttpError::internal(error.to_string())) diff --git a/src/http/handlers/mod.rs b/src/http/handlers/mod.rs index ea59c29..91a935a 100644 --- a/src/http/handlers/mod.rs +++ b/src/http/handlers/mod.rs @@ -7,8 +7,8 @@ use crate::http::error::{HttpError, HttpResult}; use crate::media::TrackKey; pub(crate) use health::{health, metrics, ready}; -pub(crate) use media::{init_segment, media_segment}; -pub(crate) use playlist::{dash_manifest, master_playlist, media_playlist}; +pub(crate) use media::{iframe_segment, init_segment, media_segment}; +pub(crate) use playlist::{dash_manifest, iframe_playlist, master_playlist, media_playlist}; pub(crate) fn parse_track(track: &str) -> HttpResult { TrackKey::parse(track).ok_or_else(|| HttpError::not_found("track does not exist")) diff --git a/src/http/handlers/playlist.rs b/src/http/handlers/playlist.rs index f5b4263..4c1014e 100644 --- a/src/http/handlers/playlist.rs +++ b/src/http/handlers/playlist.rs @@ -38,6 +38,19 @@ pub(crate) async fn media_playlist( playlist_response(asset.hls_media_playlist(key)?, etag) } +pub(crate) async fn iframe_playlist( + State(state): State, + Path(asset_id): Path, + headers: HeaderMap, +) -> HttpResult { + let asset = state.asset(&asset_id).await?; + let etag = entity_tag(&asset, "hls-iframe-playlist"); + if not_modified(&headers, &etag) { + return not_modified_response(etag, "public, max-age=60"); + } + playlist_response(asset.hls_iframe_playlist()?, etag) +} + pub(crate) async fn dash_manifest( State(state): State, Path(asset_id): Path, diff --git a/src/http/router.rs b/src/http/router.rs index b237f93..f98ccfc 100644 --- a/src/http/router.rs +++ b/src/http/router.rs @@ -9,8 +9,8 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T use tracing::Level; use super::handlers::{ - dash_manifest, health, init_segment, master_playlist, media_playlist, media_segment, metrics, - ready, + dash_manifest, health, iframe_playlist, iframe_segment, init_segment, master_playlist, + media_playlist, media_segment, metrics, ready, }; use super::middleware::{ X_REQUEST_ID, enforce_header_limit, record_metrics, request_id, shed_load, @@ -22,6 +22,8 @@ const READY: &str = "/ready"; const METRICS: &str = "/metrics"; const HLS_MASTER: &str = "/hls/{asset_id}/master.m3u8"; const HLS_MEDIA_PLAYLIST: &str = "/hls/{asset_id}/{track}/index.m3u8"; +const HLS_IFRAME_PLAYLIST: &str = "/hls/{asset_id}/video/iframes.m3u8"; +const HLS_IFRAME_SEGMENT: &str = "/hls/{asset_id}/video/iframes/{frame_index}/media.m4s"; const HLS_INIT: &str = "/hls/{asset_id}/{track}/init.mp4"; const HLS_SEGMENT: &str = "/hls/{asset_id}/{track}/segments/{segment_index}/media.m4s"; const DASH_MANIFEST: &str = "/dash/{asset_id}/manifest.mpd"; @@ -30,12 +32,14 @@ const DASH_SEGMENT: &str = "/dash/{asset_id}/{track}/segments/{segment_index}/me /// Every route template, used both to register handlers and to label metrics, so a route /// cannot be added to one and forgotten in the other. -pub(crate) const ROUTES: [&str; 10] = [ +pub(crate) const ROUTES: [&str; 12] = [ HEALTH, READY, METRICS, HLS_MASTER, HLS_MEDIA_PLAYLIST, + HLS_IFRAME_PLAYLIST, + HLS_IFRAME_SEGMENT, HLS_INIT, HLS_SEGMENT, DASH_MANIFEST, @@ -50,6 +54,8 @@ pub(crate) fn router(state: AppState) -> Router { .route(METRICS, get(metrics)) .route(HLS_MASTER, get(master_playlist)) .route(HLS_MEDIA_PLAYLIST, get(media_playlist)) + .route(HLS_IFRAME_PLAYLIST, get(iframe_playlist)) + .route(HLS_IFRAME_SEGMENT, get(iframe_segment)) .route(HLS_INIT, get(init_segment)) .route(HLS_SEGMENT, get(media_segment)) .route(DASH_MANIFEST, get(dash_manifest)) diff --git a/src/protocol/hls.rs b/src/protocol/hls.rs index aae8cc5..8b459bc 100644 --- a/src/protocol/hls.rs +++ b/src/protocol/hls.rs @@ -46,6 +46,9 @@ pub(crate) fn master_playlist(presentation: Presentation<'_>) -> Result .map_or_else(String::new, |(width, height)| { format!(",RESOLUTION={width}x{height}") }); + if let Some(iframes) = iframe_stream(presentation, video)? { + playlist.push_str(&iframes); + } let audio_attribute = if audio_group { ",AUDIO=\"audio\"" } else { "" }; writeln!( playlist, @@ -89,6 +92,126 @@ pub(crate) fn media_playlist(presentation: Presentation<'_>, key: TrackKey) -> R Ok(playlist) } +/// The I-frame playlist of the video track: one entry per keyframe, each its own one-sample +/// fragment. `None` for an asset without video. +pub(crate) fn iframe_playlist(presentation: Presentation<'_>) -> Result> { + let Some(track) = presentation.video() else { + return Ok(None); + }; + let version = presentation.version(); + let frames = keyframes(track)?; + let target_duration = frames + .entries + .iter() + .map(|frame| frame.interval.div_ceil(u64::from(track.timescale))) + .max() + .unwrap_or(1); + let mut playlist = format!( + "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:{target_duration}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-I-FRAMES-ONLY\n#EXT-X-MAP:URI=\"init.mp4?v={version}\"\n" + ); + for (index, frame) in frames.entries.iter().enumerate() { + let milliseconds = frame + .interval + .checked_mul(1000) + .and_then(|interval| interval.checked_div(u64::from(track.timescale))) + .ok_or_else(|| Error::InvalidMedia("keyframe interval overflow".to_owned()))?; + writeln!( + playlist, + "#EXTINF:{}.{:03},\niframes/{index}/media.m4s?v={version}", + milliseconds / 1000, + milliseconds % 1000 + ) + .expect("writing to a String cannot fail"); + } + playlist.push_str("#EXT-X-ENDLIST\n"); + Ok(Some(playlist)) +} + +/// The `#EXT-X-I-FRAME-STREAM-INF` line for the video track, or `None` when there is no video. +fn iframe_stream(presentation: Presentation<'_>, video: Option<&Track>) -> Result> { + let Some(track) = video else { + return Ok(None); + }; + let frames = keyframes(track)?; + let (Some(peak), Some(average)) = ( + frames.peak_bandwidth(track.timescale), + frames.average_bandwidth(track.timescale), + ) else { + return Ok(None); + }; + let resolution = track + .codec + .dimensions() + .map_or_else(String::new, |(width, height)| { + format!(",RESOLUTION={width}x{height}") + }); + Ok(Some(format!( + "#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH={peak},AVERAGE-BANDWIDTH={average},CODECS=\"{}\"{resolution},URI=\"video/iframes.m3u8?v={}\"\n", + track.codec.codecs(), + presentation.version() + ))) +} + +/// The sync samples of a track: where each is, how long it stays on screen (until the next one), +/// and how big it is. +struct Keyframes { + entries: Vec, +} + +struct Keyframe { + /// Ticks from this keyframe to the next, or to the end of the track. + interval: u64, + bytes: u64, +} + +fn keyframes(track: &Track) -> Result { + let overflow = || Error::InvalidMedia("keyframe interval overflow".to_owned()); + let sync = track + .samples + .iter() + .filter(|sample| sample.is_sync) + .collect::>(); + let end = track + .samples + .last() + .map(|last| last.decode_time.checked_add(u64::from(last.duration))) + .ok_or_else(overflow)? + .ok_or_else(overflow)?; + let mut entries = Vec::with_capacity(sync.len()); + for (position, sample) in sync.iter().enumerate() { + let next = sync.get(position + 1).map_or(end, |next| next.decode_time); + entries.push(Keyframe { + interval: next.checked_sub(sample.decode_time).ok_or_else(overflow)?, + bytes: u64::from(sample.size), + }); + } + Ok(Keyframes { entries }) +} + +impl Keyframes { + /// The largest keyframe, in bits per second over the interval it stands for. + fn peak_bandwidth(&self, timescale: u32) -> Option { + self.entries + .iter() + .filter_map(|frame| bits_per_second(frame.bytes, frame.interval, timescale)) + .max() + } + + /// All keyframes' bytes over the time they cover. + fn average_bandwidth(&self, timescale: u32) -> Option { + let bytes = self.entries.iter().map(|frame| frame.bytes).sum(); + let ticks = self.entries.iter().map(|frame| frame.interval).sum(); + bits_per_second(bytes, ticks, timescale) + } +} + +fn bits_per_second(bytes: u64, ticks: u64, timescale: u32) -> Option { + bytes + .checked_mul(8)? + .checked_mul(u64::from(timescale))? + .checked_div(ticks) +} + /// One `#EXT-X-MEDIA` line. Renditions are named by position, because the handler names encoders /// write (`SoundHandler`) say nothing to a viewer; the language is added when the file has one. fn write_audio_rendition(playlist: &mut String, track: &Track, index: usize, version: &str) { @@ -141,6 +264,44 @@ mod tests { ); } + #[test] + fn the_iframe_playlist_lists_one_fragment_per_keyframe() { + let loaded = Loaded::h264_aac(); + let keyframes = loaded.index.tracks[0] + .samples + .iter() + .filter(|sample| sample.is_sync) + .count(); + + let playlist = iframe_playlist(loaded.presentation()) + .expect("playlist should render") + .expect("a video asset has one"); + + assert!(playlist.contains("#EXT-X-I-FRAMES-ONLY\n"), "{playlist}"); + assert!(playlist.contains(&format!("#EXT-X-MAP:URI=\"init.mp4?v={}\"", loaded.version))); + assert_eq!(playlist.matches("#EXTINF:").count(), keyframes); + assert!(playlist.contains(&format!("iframes/{}/media.m4s?v=", keyframes - 1))); + let master = master_playlist(loaded.presentation()).expect("master should render"); + assert!( + master.contains("#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH="), + "{master}" + ); + assert!(master.contains(&format!("URI=\"video/iframes.m3u8?v={}\"", loaded.version))); + } + + #[test] + fn an_audio_only_asset_has_no_iframe_playlist() { + let loaded = Loaded::fixture("aac-only.m4a"); + + assert!( + iframe_playlist(loaded.presentation()) + .expect("rendering should succeed") + .is_none() + ); + let master = master_playlist(loaded.presentation()).expect("master should render"); + assert!(!master.contains("I-FRAME"), "{master}"); + } + #[test] fn master_lists_every_audio_track_and_defaults_the_first() { let loaded = Loaded::fixture("h264-aac-two-audio.mp4"); diff --git a/tests/conformance.rs b/tests/conformance.rs index 6719ad9..dcf7491 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -1106,3 +1106,123 @@ fn audio_and_video_stay_in_sync_through_edit_lists() { } } } + +/// The I-frame playlist lists exactly the source's keyframes, and each listed fragment, with the +/// video init segment in front of it, decodes to one picture. +#[test] +fn every_iframe_fragment_decodes_to_exactly_one_frame() { + let ffprobe = Command::new("ffprobe").arg("-version").output().is_ok(); + let server = start_server(); + let directory = root().join("target/conformance/iframes"); + fs::create_dir_all(&directory).unwrap(); + + for (asset, file, _) in FIXTURES { + let master = get_ok(&server, &format!("/hls/{asset}/master.m3u8")).text(); + let has_video = expected_codecs(asset).0.is_some(); + let declared = master + .lines() + .find(|line| line.starts_with("#EXT-X-I-FRAME-STREAM-INF")); + assert_eq!( + declared.is_some(), + has_video, + "{asset}: only assets with video have an I-frame stream\n{master}" + ); + let Some(declared) = declared else { + assert_eq!( + get(&server, &format!("/hls/{asset}/video/iframes.m3u8")).status, + 404, + "{asset}: no video, no I-frame playlist" + ); + continue; + }; + assert!(declared.contains("CODECS=\""), "{asset}: {declared}"); + assert!(declared.contains("BANDWIDTH="), "{asset}: {declared}"); + assert!(!declared.contains("mp4a"), "{asset}: video codec only"); + + let playlist_url = format!("/hls/{asset}/video/iframes.m3u8"); + let playlist = get_ok(&server, &playlist_url); + assert_eq!( + playlist.headers["content-type"], + "application/vnd.apple.mpegurl" + ); + let text = playlist.text(); + assert!(text.contains("#EXT-X-I-FRAMES-ONLY"), "{asset}: {text}"); + assert!(text.ends_with("#EXT-X-ENDLIST\n"), "{asset}"); + let map = text + .lines() + .find_map(|line| line.strip_prefix("#EXT-X-MAP:URI=\"")) + .and_then(|rest| rest.strip_suffix('"')) + .expect("the playlist names the init segment"); + let init = get_ok(&server, &resolve(&playlist_url, map)).body; + let entries = text + .lines() + .filter(|line| line.starts_with("iframes/")) + .collect::>(); + + if ffprobe { + assert_eq!( + entries.len() as u64, + source_keyframes(&root().join("tests/fixtures").join(file)), + "{asset}: the playlist lists the source's keyframes" + ); + } + for (index, entry) in entries.iter().enumerate() { + assert!( + entry.starts_with(&format!("iframes/{index}/media.m4s?v=")), + "{asset}: {entry}" + ); + let fragment = get_ok(&server, &resolve(&playlist_url, entry)); + assert_eq!(fragment.headers["content-type"], "video/mp4"); + if !ffprobe || index % 4 != 0 { + continue; + } + let path = directory.join(format!("{asset}-{index}.mp4")); + let mut bytes = init.clone(); + bytes.extend_from_slice(&fragment.body); + fs::write(&path, bytes).unwrap(); + assert_eq!( + probe_frames(&path, "v:0"), + 1, + "{asset}: fragment {index} holds one picture" + ); + let errors = Command::new("ffmpeg") + .args(["-v", "error", "-i"]) + .arg(&path) + .args(["-f", "null", "-"]) + .output(); + if let Ok(output) = errors { + assert!( + output.stderr.is_empty(), + "{asset}: fragment {index} decode errors: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + let past_the_end = get( + &server, + &resolve( + &playlist_url, + &format!( + "iframes/{}/media.m4s?v={}", + entries.len(), + entries[0].rsplit('=').next().unwrap() + ), + ), + ); + assert_eq!(past_the_end.status, 404, "{asset}: no such keyframe"); + } +} + +/// The number of keyframes FFprobe finds in the first video stream. +fn source_keyframes(path: &Path) -> u64 { + let output = Command::new("ffprobe") + .args(["-v", "error", "-select_streams", "v:0"]) + .args(["-show_entries", "packet=flags", "-of", "csv=p=0"]) + .arg(path) + .output() + .expect("ffprobe should run"); + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| line.starts_with('K')) + .count() as u64 +} From 4c6e23bfcee3f352b370506b7e1eaf1879bb4684 Mon Sep 17 00:00:00 2001 From: includeamin Date: Mon, 21 Sep 2026 18:43:39 +0200 Subject: [PATCH 4/7] build(lint): deny more dead code and leftovers, and check for unused dependencies Signed-off-by: includeamin --- .github/workflows/ci.yml | 15 +++++++++++++++ CONTRIBUTING.md | 7 +++++-- Cargo.toml | 29 +++++++++++++++++++++++++++++ Makefile | 8 ++++++-- benches/budgets.rs | 3 ++- src/fmp4/init.rs | 4 ++-- src/http/error.rs | 2 +- src/http/range.rs | 2 +- src/http/stream.rs | 8 ++++---- src/http/tests.rs | 2 +- src/mp4/parser.rs | 4 ++-- src/observability/metrics.rs | 2 +- src/source/metadata.rs | 16 +++++++--------- tests/conformance.rs | 11 +++++------ 14 files changed, 81 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4954667..5bea9f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,18 @@ jobs: - name: Run validation suite run: make ci + + unused-dependencies: + name: Unused dependencies + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install cargo-machete + uses: taiki-e/install-action@v2 + with: + tool: cargo-machete + + - name: Fail on dependencies nothing uses + run: cargo machete diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 190e4b4..c9752fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,11 @@ explains how the code is organised and [testing](docs/internals/testing.md) how - **Bounded by construction.** Anything read from a file or a mapper is untrusted. Check a length against the bytes available before allocating, use checked arithmetic, and keep the limits in `[limits]` meaningful. `unsafe` is forbidden. -- **Lint clean.** Clippy runs with `pedantic` and warnings are errors. Prefer fixing the code to - allowing the lint, and say why when you allow one. +- **Lint clean.** `make lint` runs Clippy with `pedantic` and the extra lints in `Cargo.toml`, and + warnings are errors. That includes code nothing calls (dead code), unused imports and variables, + and leftover `dbg!`, `todo!`, and `unimplemented!`. Delete unused code instead of silencing the + warning; when you must allow a lint, use `#[allow(..., reason = "...")]` and say why. CI also + runs `make unused-deps` (cargo-machete) to fail on dependencies nothing uses. - **Documented.** Behaviour that operators or mapper authors see belongs in the handbook. A change that alters an accepted design belongs in a design document first; see below. diff --git a/Cargo.toml b/Cargo.toml index aa2ba87..e348474 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,15 @@ strip = "debuginfo" [lints.rust] missing_docs = "warn" unsafe_code = "forbid" +# Code that is never used is deleted, not left behind: dead_code is on by default, and CI turns +# every warning into an error. +unused_qualifications = "warn" +unused_import_braces = "warn" +unused_lifetimes = "warn" +trivial_numeric_casts = "warn" +redundant_lifetimes = "warn" +single_use_lifetimes = "warn" +unit_bindings = "warn" [lints.rustdoc] broken_intra_doc_links = "deny" @@ -59,3 +68,23 @@ broken_intra_doc_links = "deny" [lints.clippy] all = "warn" pedantic = "warn" +# Leftovers from debugging and unfinished work must not reach main. +dbg_macro = "warn" +todo = "warn" +unimplemented = "warn" +# Correctness and clarity picks from the restriction group. +allow_attributes_without_reason = "warn" +mem_forget = "warn" +rc_mutex = "warn" +str_to_string = "warn" +unused_result_ok = "warn" +verbose_file_reads = "warn" +empty_drop = "warn" +if_then_some_else_none = "warn" +needless_raw_strings = "warn" +redundant_type_annotations = "warn" +# Rust's own `Result<_, ()>` and friends aside, these catch code that compiles but is dead weight. +redundant_clone = "warn" +useless_let_if_seq = "warn" +unnecessary_self_imports = "warn" +unnecessary_wraps = "warn" diff --git a/Makefile b/Makefile index 9c059ab..c1ff98f 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ CARGO ?= cargo MDBOOK ?= mdbook -.PHONY: bench test-scripts bench-enforce conformance book book-serve book-test build check ci clean doc doc-open fixtures fmt fmt-check fuzz fuzz-check install-doc-tools lint serve demo site test deny +.PHONY: unused-deps bench test-scripts bench-enforce conformance book book-serve book-test build check ci clean doc doc-open fixtures fmt fmt-check fuzz fuzz-check install-doc-tools lint serve demo site test deny help: ## Show the available targets @printf '%s\n' \ @@ -29,6 +29,7 @@ help: ## Show the available targets 'lint Run Clippy with warnings denied' \ 'serve Run the example HLS service' \ 'demo Serve the web player demo on http://127.0.0.1:8080' \ + 'unused-deps Fail on dependencies that nothing uses (needs cargo-machete)' \ 'deny Check dependencies for advisories and licences (needs cargo-deny)' \ 'site Build mdBook with rustdoc under /api' \ 'test Run unit and integration tests' @@ -75,9 +76,12 @@ fuzz: ## Run media pipeline fuzzing with nightly cp tests/fixtures/*.mp4 fuzz/corpus/media-pipeline/ $(CARGO) +nightly fuzz run media-pipeline fuzz/corpus/media-pipeline -lint: ## Run Clippy with warnings denied +lint: ## Run Clippy with warnings denied (dead code, unused imports, and the lints in Cargo.toml) $(CARGO) clippy --all-features --all-targets -- -D warnings +unused-deps: ## Fail on dependencies nothing uses (needs `cargo install cargo-machete --locked`) + cargo machete + test: ## Run unit and integration tests $(CARGO) test --all-features --all-targets diff --git a/benches/budgets.rs b/benches/budgets.rs index 3a2d7c2..f2566c0 100644 --- a/benches/budgets.rs +++ b/benches/budgets.rs @@ -23,7 +23,8 @@ clippy::large_futures, clippy::redundant_closure_for_method_calls, clippy::too_many_lines, - clippy::trivially_copy_pass_by_ref + clippy::trivially_copy_pass_by_ref, + reason = "harness code, not the production crate" )] use std::path::{Path, PathBuf}; diff --git a/src/fmp4/init.rs b/src/fmp4/init.rs index 53716fb..e547928 100644 --- a/src/fmp4/init.rs +++ b/src/fmp4/init.rs @@ -371,8 +371,8 @@ mod tests { assert!(!contains(&rewritten, b"wave") && !contains(&rewritten, b"chan")); // Same audio, described the standard way. assert_eq!( - crate::mp4::codec::parse_aac(&rewritten, 2).unwrap(), - crate::mp4::codec::parse_aac(&source, 2).unwrap() + mp4::codec::parse_aac(&rewritten, 2).unwrap(), + mp4::codec::parse_aac(&source, 2).unwrap() ); assert_eq!( &rewritten[..8], diff --git a/src/http/error.rs b/src/http/error.rs index 69dbd3d..1587871 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -9,7 +9,7 @@ use crate::registry::RegistryError; const NO_STORE: HeaderValue = HeaderValue::from_static("no-store"); -pub(crate) type HttpResult = std::result::Result; +pub(crate) type HttpResult = Result; #[derive(Debug)] pub(crate) struct HttpError { diff --git a/src/http/range.rs b/src/http/range.rs index c755cb3..7ebc491 100644 --- a/src/http/range.rs +++ b/src/http/range.rs @@ -31,7 +31,7 @@ pub(crate) fn requested_range( headers: &HeaderMap, total: u64, etag: &HeaderValue, -) -> std::result::Result, ()> { +) -> Result, ()> { let Some(value) = headers.get(RANGE) else { return Ok(None); }; diff --git a/src/http/stream.rs b/src/http/stream.rs index 66583ad..fd2fb7a 100644 --- a/src/http/stream.rs +++ b/src/http/stream.rs @@ -37,7 +37,7 @@ impl StreamJob { } } - async fn stream(&mut self) -> std::result::Result<(), StreamAbort> { + async fn stream(&mut self) -> Result<(), StreamAbort> { let header_end = self.prepared.header.len() as u64; if let Some(overlap) = self.interval.overlap(0, header_end) { let (Ok(start), Ok(end)) = @@ -69,7 +69,7 @@ impl StreamJob { Ok(()) } - async fn read(&mut self, offset: u64, length: u64) -> std::result::Result { + async fn read(&mut self, offset: u64, length: u64) -> Result { let permit = match self.first_permit.take() { Some(permit) => permit, None => match acquire_segment_permit(&self.segment_jobs, self.queue_timeout).await { @@ -96,7 +96,7 @@ impl StreamJob { } } - async fn send(&self, item: std::io::Result) -> std::result::Result<(), StreamAbort> { + async fn send(&self, item: std::io::Result) -> Result<(), StreamAbort> { let length = item.as_ref().map_or(0, Bytes::len); match timeout(self.idle_timeout, self.sender.send(item)).await { Ok(Ok(())) => { @@ -112,7 +112,7 @@ impl StreamJob { } /// Surfaces a generic body error to the client, then reports the abort. - async fn fail(&self, message: &'static str) -> std::result::Result { + async fn fail(&self, message: &'static str) -> Result { let _ = self.send(Err(std::io::Error::other(message))).await; Err(StreamAbort::Error) } diff --git a/src/http/tests.rs b/src/http/tests.rs index aa91f22..f7a835c 100644 --- a/src/http/tests.rs +++ b/src/http/tests.rs @@ -680,7 +680,7 @@ async fn ffmpeg_decodes_hls_and_dash_presentations() { eprintln!("skipping media validation because ffmpeg is unavailable"); return; } - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app()).await.unwrap(); diff --git a/src/mp4/parser.rs b/src/mp4/parser.rs index bfe9a9f..9f97544 100644 --- a/src/mp4/parser.rs +++ b/src/mp4/parser.rs @@ -561,7 +561,7 @@ mod tests { use crate::media::CodecConfig; use crate::source::LocalMediaSource; - fn block_on(future: F) -> F::Output { + fn block_on(future: F) -> F::Output { tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -1228,7 +1228,7 @@ mod tests { #[test] fn rejects_run_length_entries_that_claim_more_samples_than_stsz() { let original = std::fs::read(fixture("h264-aac.mp4")).expect("fixture should read"); - let mut mutated = original.clone(); + let mut mutated = original; // stts payload: version/flags (4), entry count (4), then (sample_count, delta) pairs. let stts = find_type(&mutated, *b"stts"); mutated[stts + 12..stts + 16].copy_from_slice(&i32::MAX.to_be_bytes()); diff --git a/src/observability/metrics.rs b/src/observability/metrics.rs index a5bc13a..bc952f9 100644 --- a/src/observability/metrics.rs +++ b/src/observability/metrics.rs @@ -262,7 +262,7 @@ impl Metrics { .fetch_add(1, Relaxed); } - #[allow(clippy::too_many_lines)] + #[allow(clippy::too_many_lines, reason = "one flat listing of every metric")] pub(crate) fn render(&self, dropped_log_lines: usize) -> String { let mut out = String::with_capacity(8 * 1024); let _ = writeln!( diff --git a/src/source/metadata.rs b/src/source/metadata.rs index a406a28..cfd85f5 100644 --- a/src/source/metadata.rs +++ b/src/source/metadata.rs @@ -429,19 +429,17 @@ impl<'a> Walk<'a> { } // A cut `mdat` belongs to the `moof` just before it, which now describes samples that are // not all there. - let mut from = offset; - let mut dropped_fragments = 0; - if cut == Cut::Mdat + let (from, dropped_fragments) = if cut == Cut::Mdat && let Some(moof) = self.last_moof.take_if(|start| { self.fragments .last() .is_some_and(|fragment| fragment.offset == *start) - }) - { + }) { self.fragments.pop(); - from = moof; - dropped_fragments = 1; - } + (moof, 1) + } else { + (offset, 0) + }; if self.fragments.is_empty() { return Err(invalid( "the file ends before its first fragment is complete", @@ -1092,7 +1090,7 @@ mod tests { ("zero.mp4", zero, 0), ("past.mp4", past_the_end, 0), // Media said to start in the middle of a box. - ("misaligned.mp4", honest.clone(), 5), + ("misaligned.mp4", honest, 5), ] { let file = with_sidx(&head, &parts, &sizes, first_offset); diff --git a/tests/conformance.rs b/tests/conformance.rs index dcf7491..d3da73b 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -22,7 +22,8 @@ clippy::large_futures, clippy::redundant_closure_for_method_calls, clippy::too_many_lines, - clippy::trivially_copy_pass_by_ref + clippy::trivially_copy_pass_by_ref, + reason = "harness code, not the production crate" )] use std::collections::HashMap; @@ -387,13 +388,11 @@ fn parse_fragment(data: &[u8]) -> Fragment { let flags = be32(trun) & 0x00ff_ffff; let count = be32(&trun[4..]) as usize; let mut cursor = 8; - let data_offset = if flags & 0x1 != 0 { + let data_offset = (flags & 0x1 != 0).then(|| { let value = i32::from_be_bytes(trun[cursor..cursor + 4].try_into().unwrap()); cursor += 4; - Some(value) - } else { - None - }; + value + }); if flags & 0x4 != 0 { cursor += 4; } From 8a9497c88b53db97054a92f91757dc1d0c7fb282 Mon Sep 17 00:00:00 2001 From: includeamin Date: Mon, 21 Sep 2026 18:43:39 +0200 Subject: [PATCH 5/7] feat(subtitles): serve sidecar WebVTT files listed by the mapper Signed-off-by: includeamin --- README.md | 2 +- docs/mapper-api.md | 36 +++ ...006-trick-play-subtitles-and-renditions.md | 2 + docs/usage.md | 3 + src/asset.rs | 95 ++++++- src/config/limits.rs | 12 + src/http/handlers/media.rs | 34 +++ src/http/handlers/mod.rs | 6 +- src/http/handlers/playlist.rs | 16 ++ src/http/router.rs | 13 +- src/lib.rs | 1 + src/protocol/dash.rs | 27 ++ src/protocol/hls.rs | 40 ++- src/protocol/presentation.rs | 21 ++ src/registry/mod.rs | 57 ++++- src/registry/opener.rs | 46 +++- src/registry/tests.rs | 240 ++++++++++++++++++ src/resolver/catalog.rs | 1 + src/resolver/mapper.rs | 108 +++++++- src/resolver/mod.rs | 13 + src/subtitle.rs | 198 +++++++++++++++ src/testutil.rs | 16 ++ tests/fixtures/generate-variants.sh | 9 + tests/fixtures/h264-aac-video-delay.mp4 | Bin 0 -> 140567 bytes tests/fixtures/subtitles-bad.srt | 3 + tests/fixtures/subtitles-en.vtt | 7 + tests/fixtures/subtitles-fr.vtt | 4 + 27 files changed, 978 insertions(+), 32 deletions(-) create mode 100644 src/subtitle.rs create mode 100644 tests/fixtures/h264-aac-video-delay.mp4 create mode 100644 tests/fixtures/subtitles-bad.srt create mode 100644 tests/fixtures/subtitles-en.vtt create mode 100644 tests/fixtures/subtitles-fr.vtt diff --git a/README.md b/README.md index 204e0ca..97f558d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ proportional to the segment asked for, not to the length of the video. ## What it does not do - **No transcoding.** The codecs must already suit the protocol, and there is no bitrate ladder - unless you supply the renditions. Adaptive renditions and WebVTT subtitles are + unless you supply the renditions. Adaptive renditions are [planned](docs/technical-design/0006-trick-play-subtitles-and-renditions.md). - **No DRM, no live streaming, no MPEG-TS output.** Segments are fragmented MP4. DRM is planned after the features above. diff --git a/docs/mapper-api.md b/docs/mapper-api.md index 7dfaf06..d6be311 100644 --- a/docs/mapper-api.md +++ b/docs/mapper-api.md @@ -58,6 +58,7 @@ A file on an HTTP origin: | `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](#remote-locations) | +| `subtitles` | No | Sidecar WebVTT files; see [Subtitles](#subtitles) | ### `304 Not Modified` @@ -105,6 +106,40 @@ Signed query parameters are treated as secrets: they are not logged at `info` an 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: + +```json +{ + "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. + +If the media's timeline was moved (an edit list or a late start), the server adds the same offset to every cue, so cues authored against the file's own clock stay in step with the picture. 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. @@ -164,5 +199,6 @@ Then `curl http://127.0.0.1:3000/hls/movie/master.m3u8`. - Removed assets answer `404` or `410`, not `200`. - Answers are small (the server's default limit is 16 KiB). - The mapper answers quickly: the server's default per-request timeout is two seconds, with two retries. +- `version` also changes when a subtitle file changes. - `expires_at` is set for anything signed, and re-signing keeps the same `version`. - The mapper is reachable over `https` in production and requires the bearer token. diff --git a/docs/technical-design/0006-trick-play-subtitles-and-renditions.md b/docs/technical-design/0006-trick-play-subtitles-and-renditions.md index 6f541e2..5b8c511 100644 --- a/docs/technical-design/0006-trick-play-subtitles-and-renditions.md +++ b/docs/technical-design/0006-trick-play-subtitles-and-renditions.md @@ -62,6 +62,8 @@ Each I-frame resource, prefixed with the init segment, must decode to exactly on ## 2. Sidecar WebVTT subtitles +> **Implemented** as designed, with two refinements: an `http` subtitle origin must support ranged requests (it is opened like media, so it gets the same `[remote_media]` and redirect protection), and the size and count limits are `limits.max_subtitle_bytes`, `limits.max_subtitles_total_bytes`, and `limits.max_subtitles`. The version covers subtitle content; a mapper must still change its own `version` when a caption changes, because that is what triggers a reload. Not yet checked in a browser. + ### Mapper answer An optional `subtitles` list, each entry: diff --git a/docs/usage.md b/docs/usage.md index 14c0e5b..f4495cb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -20,7 +20,10 @@ Each asset exposes: /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 diff --git a/src/asset.rs b/src/asset.rs index 2d8488c..ec03aa7 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -5,11 +5,12 @@ use bytes::Bytes; use crate::config::LimitsConfig; use crate::error::{Error, Result}; -use crate::media::{MediaIndex, Sample, Track, TrackKey}; +use crate::media::{MediaIndex, Sample, Track, TrackKey, TrackKind}; use crate::mp4::ParsedMedia; use crate::protocol::{Presentation, dash, hls}; use crate::segment::{SegmentPlan, TrackSegment}; use crate::source::{ByteRange, LocalMediaSource, MediaSourceKind}; +use crate::subtitle::{self, Subtitle}; use crate::{fmp4, mp4, segment}; use std::sync::Arc; @@ -22,6 +23,7 @@ pub(crate) struct PackagedAsset { limits: LimitsConfig, version: String, rendered: RenderedManifests, + subtitles: Vec, } /// Playlists and manifests rendered once at load so requests never walk sample tables. @@ -30,6 +32,7 @@ struct RenderedManifests { hls_master: Bytes, hls_media: HashMap, hls_iframes: Option, + hls_subtitle: Bytes, dash: Bytes, } @@ -52,11 +55,22 @@ impl PackagedAsset { source: MediaSourceKind, segment_duration_ms: u64, limits: &LimitsConfig, + ) -> Result { + Self::load_with_subtitles(source, Vec::new(), segment_duration_ms, limits).await + } + + /// Like [`Self::load`], with sidecar subtitle files already fetched. Each is validated and + /// moved onto the asset's timeline; one bad file fails the whole asset. + pub(crate) async fn load_with_subtitles( + source: MediaSourceKind, + subtitles: Vec, + segment_duration_ms: u64, + limits: &LimitsConfig, ) -> Result { let parsed = mp4::parse(&source, limits).await?; let limits = limits.clone(); tokio::task::spawn_blocking(move || { - Self::assemble(source, parsed, segment_duration_ms, &limits) + Self::assemble(source, parsed, subtitles, segment_duration_ms, &limits) }) .await .map_err(|error| Error::Io(std::io::Error::other(error)))? @@ -65,11 +79,13 @@ impl PackagedAsset { fn assemble( source: MediaSourceKind, parsed: ParsedMedia, + subtitles: Vec, segment_duration_ms: u64, limits: &LimitsConfig, ) -> Result { let ParsedMedia { index, metadata } = parsed; let plan = segment::plan(&index, segment_duration_ms, limits)?; + let subtitles = prepare_subtitles(&index, subtitles)?; let init_segments = index .tracks .iter() @@ -79,9 +95,10 @@ impl PackagedAsset { .map(|bytes| (track.key, bytes)) }) .collect::>>()?; - let version = version_of(&index); - let rendered = - RenderedManifests::render(Presentation::new(&index.tracks, &plan, &version))?; + let version = version_of(&index, &subtitles); + let rendered = RenderedManifests::render( + Presentation::new(&index.tracks, &plan, &version).with_subtitles(&subtitles), + )?; Ok(Self { source, index, @@ -90,6 +107,7 @@ impl PackagedAsset { limits: limits.clone(), version, rendered, + subtitles, }) } @@ -100,6 +118,7 @@ impl PackagedAsset { /// The read-only view renderers work from. pub(crate) fn presentation(&self) -> Presentation<'_> { Presentation::new(&self.index.tracks, &self.plan, &self.version) + .with_subtitles(&self.subtitles) } pub(crate) fn init_segment(&self, key: TrackKey) -> Result { @@ -159,6 +178,21 @@ impl PackagedAsset { fmp4::prepare_media_segment(track, segment, sequence_number, &self.limits) } + /// The HLS playlist that lists one subtitle file. + pub(crate) fn hls_subtitle_playlist(&self, language: &str) -> Result { + self.subtitle(language)?; + Ok(self.rendered.hls_subtitle.clone()) + } + + /// A subtitle file, ready to serve. + pub(crate) fn subtitle(&self, language: &str) -> Result { + self.subtitles + .iter() + .find(|subtitle| subtitle.language.eq_ignore_ascii_case(language)) + .map(|subtitle| subtitle.data.clone()) + .ok_or(Error::NotFound("subtitle does not exist")) + } + pub(crate) fn dash_manifest(&self) -> Bytes { self.rendered.dash.clone() } @@ -264,9 +298,15 @@ impl PackagedAsset { .values() .map(Bytes::len) .sum::(); - (samples.saturating_mul(std::mem::size_of::()) as u64) + (samples.saturating_mul(size_of::()) as u64) .saturating_add(init as u64) .saturating_add(rendered as u64) + .saturating_add( + self.subtitles + .iter() + .map(|subtitle| subtitle.data.len() as u64) + .sum(), + ) } } @@ -284,6 +324,7 @@ impl RenderedManifests { hls_master: Bytes::from(hls::master_playlist(presentation)?), hls_media, hls_iframes: hls::iframe_playlist(presentation)?.map(Bytes::from), + hls_subtitle: Bytes::from(hls::subtitle_playlist(presentation)), dash: Bytes::from(dash::manifest(presentation)?), }) } @@ -297,9 +338,33 @@ impl RenderedManifests { /// revision into the version gives such a build new URLs instead. const FORMAT_REVISION: u32 = 2; +/// Validates each subtitle file and moves its cues onto the asset's timeline: by the offset the +/// reference track was shifted by, which is the video track, or the first audio track without one. +fn prepare_subtitles(index: &MediaIndex, subtitles: Vec) -> Result> { + let reference = index + .tracks + .iter() + .find(|track| track.kind == TrackKind::Video) + .or_else(|| index.tracks.first()); + let offset_ms = reference.map_or(0, |track| { + (u128::from(track.timeline_shift) * 1000 + u128::from(track.timescale) / 2) + / u128::from(track.timescale) + }); + let offset_ms = u64::try_from(offset_ms) + .map_err(|_| Error::InvalidMedia("timeline offset overflow".to_owned()))?; + subtitles + .into_iter() + .map(|mut subtitle| { + subtitle.data = subtitle::prepare(&subtitle.language, &subtitle.data, offset_ms)?; + Ok(subtitle) + }) + .collect() +} + /// The `v` value in media URLs: a hash of everything the index was built from (`moov`, and every -/// `moof` of a fragmented file) and [`FORMAT_REVISION`]. -fn version_of(index: &MediaIndex) -> String { +/// `moof` of a fragmented file), [`FORMAT_REVISION`], and any subtitle files, so changing a +/// caption gives new URLs. +fn version_of(index: &MediaIndex, subtitles: &[Subtitle]) -> String { use std::fmt::Write; use sha2::{Digest, Sha256}; @@ -312,6 +377,18 @@ fn version_of(index: &MediaIndex) -> String { .expect("parsed assets always have a metadata hash"), ); hasher.update(FORMAT_REVISION.to_be_bytes()); + // Absent subtitles add nothing, so an asset without them keeps the version it always had. + for subtitle in subtitles { + for part in [ + subtitle.language.as_bytes(), + subtitle.label.as_bytes(), + &[u8::from(subtitle.default), u8::from(subtitle.forced)], + &subtitle.data, + ] { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); + } + } hasher .finalize() .iter() @@ -349,7 +426,7 @@ mod tests { assert!(asset.version().bytes().all(|byte| byte.is_ascii_hexdigit())); assert_ne!(asset.version(), moov_only); assert_eq!( - version_of(&asset.index), + version_of(&asset.index, &[]), asset.version(), "and it is stable" ); diff --git a/src/config/limits.rs b/src/config/limits.rs index 1521a14..70f779d 100644 --- a/src/config/limits.rs +++ b/src/config/limits.rs @@ -30,6 +30,12 @@ pub(crate) struct LimitsConfig { pub(crate) max_index_bytes: u64, pub(crate) max_connections: usize, pub(crate) header_read_timeout_ms: u64, + /// Sidecar subtitle files per asset. + pub(crate) max_subtitles: usize, + /// One subtitle file. + pub(crate) max_subtitle_bytes: u64, + /// All of one asset's subtitle files together. + pub(crate) max_subtitles_total_bytes: u64, } impl LimitsConfig { @@ -54,6 +60,9 @@ impl LimitsConfig { || self.max_index_bytes == 0 || self.max_connections == 0 || self.header_read_timeout_ms == 0 + || self.max_subtitles == 0 + || self.max_subtitle_bytes == 0 + || self.max_subtitles_total_bytes == 0 { return Err(Error::Configuration( "all resource limits must be greater than zero".to_owned(), @@ -87,6 +96,9 @@ impl Default for LimitsConfig { max_index_bytes: 4 * 1024 * 1024 * 1024, max_connections: 10_000, header_read_timeout_ms: 10_000, + max_subtitles: 16, + max_subtitle_bytes: 2 * 1024 * 1024, + max_subtitles_total_bytes: 8 * 1024 * 1024, } } } diff --git a/src/http/handlers/media.rs b/src/http/handlers/media.rs index e70921d..19c3c73 100644 --- a/src/http/handlers/media.rs +++ b/src/http/handlers/media.rs @@ -83,6 +83,40 @@ pub(crate) async fn media_segment( .await } +/// A sidecar `WebVTT` file, served under both protocols. +pub(crate) async fn subtitle_file( + State(state): State, + Path((asset_id, language)): Path<(String, String)>, + Query(version): Query, + headers: HeaderMap, +) -> HttpResult { + let asset = state.asset(&asset_id).await?; + version.require(&asset)?; + let etag = entity_tag( + &asset, + &format!("subtitle-{}", language.to_ascii_lowercase()), + ); + if not_modified(&headers, &etag) { + return not_modified_response(etag, "public, max-age=31536000, immutable"); + } + let bytes = asset.subtitle(&language)?; + let total = bytes.len() as u64; + let Ok(range) = requested_range(&headers, total, &etag) else { + return range_not_satisfiable(total); + }; + let selected = range.unwrap_or(ByteInterval { + start: 0, + end: total, + }); + let (start, end) = ( + usize::try_from(selected.start).map_err(|_| HttpError::internal("range".to_owned()))?, + usize::try_from(selected.end).map_err(|_| HttpError::internal("range".to_owned()))?, + ); + media_response_builder(total, selected, etag, "text/vtt; charset=utf-8") + .body(Body::from(bytes.slice(start..end))) + .map_err(|error| HttpError::internal(error.to_string())) +} + /// One keyframe of the video track as a fragment of its own, for HLS I-frame playlists. pub(crate) async fn iframe_segment( State(state): State, diff --git a/src/http/handlers/mod.rs b/src/http/handlers/mod.rs index 91a935a..feea074 100644 --- a/src/http/handlers/mod.rs +++ b/src/http/handlers/mod.rs @@ -7,8 +7,10 @@ use crate::http::error::{HttpError, HttpResult}; use crate::media::TrackKey; pub(crate) use health::{health, metrics, ready}; -pub(crate) use media::{iframe_segment, init_segment, media_segment}; -pub(crate) use playlist::{dash_manifest, iframe_playlist, master_playlist, media_playlist}; +pub(crate) use media::{iframe_segment, init_segment, media_segment, subtitle_file}; +pub(crate) use playlist::{ + dash_manifest, iframe_playlist, master_playlist, media_playlist, subtitle_playlist, +}; pub(crate) fn parse_track(track: &str) -> HttpResult { TrackKey::parse(track).ok_or_else(|| HttpError::not_found("track does not exist")) diff --git a/src/http/handlers/playlist.rs b/src/http/handlers/playlist.rs index 4c1014e..fd4eb0f 100644 --- a/src/http/handlers/playlist.rs +++ b/src/http/handlers/playlist.rs @@ -51,6 +51,22 @@ pub(crate) async fn iframe_playlist( playlist_response(asset.hls_iframe_playlist()?, etag) } +pub(crate) async fn subtitle_playlist( + State(state): State, + Path((asset_id, language)): Path<(String, String)>, + headers: HeaderMap, +) -> HttpResult { + let asset = state.asset(&asset_id).await?; + let etag = entity_tag( + &asset, + &format!("hls-subtitle-{}-playlist", language.to_ascii_lowercase()), + ); + if not_modified(&headers, &etag) { + return not_modified_response(etag, "public, max-age=60"); + } + playlist_response(asset.hls_subtitle_playlist(&language)?, etag) +} + pub(crate) async fn dash_manifest( State(state): State, Path(asset_id): Path, diff --git a/src/http/router.rs b/src/http/router.rs index f98ccfc..b8e5366 100644 --- a/src/http/router.rs +++ b/src/http/router.rs @@ -10,7 +10,7 @@ use tracing::Level; use super::handlers::{ dash_manifest, health, iframe_playlist, iframe_segment, init_segment, master_playlist, - media_playlist, media_segment, metrics, ready, + media_playlist, media_segment, metrics, ready, subtitle_file, subtitle_playlist, }; use super::middleware::{ X_REQUEST_ID, enforce_header_limit, record_metrics, request_id, shed_load, @@ -24,6 +24,9 @@ const HLS_MASTER: &str = "/hls/{asset_id}/master.m3u8"; const HLS_MEDIA_PLAYLIST: &str = "/hls/{asset_id}/{track}/index.m3u8"; const HLS_IFRAME_PLAYLIST: &str = "/hls/{asset_id}/video/iframes.m3u8"; const HLS_IFRAME_SEGMENT: &str = "/hls/{asset_id}/video/iframes/{frame_index}/media.m4s"; +const HLS_SUBTITLE_PLAYLIST: &str = "/hls/{asset_id}/subtitles/{language}/index.m3u8"; +const HLS_SUBTITLE_FILE: &str = "/hls/{asset_id}/subtitles/{language}/sub.vtt"; +const DASH_SUBTITLE_FILE: &str = "/dash/{asset_id}/subtitles/{language}/sub.vtt"; const HLS_INIT: &str = "/hls/{asset_id}/{track}/init.mp4"; const HLS_SEGMENT: &str = "/hls/{asset_id}/{track}/segments/{segment_index}/media.m4s"; const DASH_MANIFEST: &str = "/dash/{asset_id}/manifest.mpd"; @@ -32,7 +35,7 @@ const DASH_SEGMENT: &str = "/dash/{asset_id}/{track}/segments/{segment_index}/me /// Every route template, used both to register handlers and to label metrics, so a route /// cannot be added to one and forgotten in the other. -pub(crate) const ROUTES: [&str; 12] = [ +pub(crate) const ROUTES: [&str; 15] = [ HEALTH, READY, METRICS, @@ -40,6 +43,9 @@ pub(crate) const ROUTES: [&str; 12] = [ HLS_MEDIA_PLAYLIST, HLS_IFRAME_PLAYLIST, HLS_IFRAME_SEGMENT, + HLS_SUBTITLE_PLAYLIST, + HLS_SUBTITLE_FILE, + DASH_SUBTITLE_FILE, HLS_INIT, HLS_SEGMENT, DASH_MANIFEST, @@ -56,6 +62,9 @@ pub(crate) fn router(state: AppState) -> Router { .route(HLS_MEDIA_PLAYLIST, get(media_playlist)) .route(HLS_IFRAME_PLAYLIST, get(iframe_playlist)) .route(HLS_IFRAME_SEGMENT, get(iframe_segment)) + .route(HLS_SUBTITLE_PLAYLIST, get(subtitle_playlist)) + .route(HLS_SUBTITLE_FILE, get(subtitle_file)) + .route(DASH_SUBTITLE_FILE, get(subtitle_file)) .route(HLS_INIT, get(init_segment)) .route(HLS_SEGMENT, get(media_segment)) .route(DASH_MANIFEST, get(dash_manifest)) diff --git a/src/lib.rs b/src/lib.rs index 26c904e..9a82ddb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ mod registry; mod resolver; mod segment; mod source; +mod subtitle; #[cfg(test)] mod testutil; diff --git a/src/protocol/dash.rs b/src/protocol/dash.rs index 9b495d4..644dc32 100644 --- a/src/protocol/dash.rs +++ b/src/protocol/dash.rs @@ -4,6 +4,7 @@ use super::Presentation; use super::hls::track_language; use crate::error::{Error, Result}; use crate::media::Track; +use crate::subtitle::Subtitle; pub(crate) fn manifest(presentation: Presentation<'_>) -> Result { let duration = presentation_duration(presentation)?; @@ -17,6 +18,9 @@ pub(crate) fn manifest(presentation: Presentation<'_>) -> Result { for audio in presentation.audio_tracks() { write_audio_adaptation(&mut manifest, presentation, audio, version)?; } + for subtitle in presentation.subtitles() { + write_subtitle_adaptation(&mut manifest, subtitle, version); + } manifest.push_str(" \n\n"); Ok(manifest) } @@ -71,6 +75,29 @@ fn write_audio_adaptation( Ok(()) } +/// A sidecar `WebVTT` file as a text adaptation set that names the file directly. +fn write_subtitle_adaptation(manifest: &mut String, subtitle: &Subtitle, version: &str) { + let mut roles = String::new(); + if subtitle.forced { + roles.push_str( + " \n", + ); + } else { + roles.push_str( + " \n", + ); + } + if subtitle.default { + roles.push_str(" \n"); + } + writeln!( + manifest, + " \n{roles} \n subtitles/{language}/sub.vtt?v={version}\n \n ", + language = subtitle.language + ) + .expect("writing to a String cannot fail"); +} + fn write_segment_template( manifest: &mut String, presentation: Presentation<'_>, diff --git a/src/protocol/hls.rs b/src/protocol/hls.rs index 8b459bc..12834a1 100644 --- a/src/protocol/hls.rs +++ b/src/protocol/hls.rs @@ -3,6 +3,7 @@ use std::fmt::Write; use super::Presentation; use crate::error::{Error, Result}; use crate::media::{Track, TrackKey}; +use crate::subtitle::Subtitle; pub(crate) fn master_playlist(presentation: Presentation<'_>) -> Result { let video = presentation.video(); @@ -30,6 +31,10 @@ pub(crate) fn master_playlist(presentation: Presentation<'_>) -> Result } } + for subtitle in presentation.subtitles() { + write_subtitle_rendition(&mut playlist, subtitle, version); + } + let mut bandwidth = 0u64; let mut average_bandwidth = 0u64; for track in video.into_iter().chain(audio) { @@ -50,9 +55,14 @@ pub(crate) fn master_playlist(presentation: Presentation<'_>) -> Result playlist.push_str(&iframes); } let audio_attribute = if audio_group { ",AUDIO=\"audio\"" } else { "" }; + let subtitle_attribute = if presentation.subtitles().is_empty() { + "" + } else { + ",SUBTITLES=\"subs\"" + }; writeln!( playlist, - "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},AVERAGE-BANDWIDTH={average_bandwidth},CODECS=\"{}\"{resolution}{audio_attribute}", + "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},AVERAGE-BANDWIDTH={average_bandwidth},CODECS=\"{}\"{resolution}{audio_attribute}{subtitle_attribute}", codecs.join(",") ) .expect("writing to a String cannot fail"); @@ -92,6 +102,17 @@ pub(crate) fn media_playlist(presentation: Presentation<'_>, key: TrackKey) -> R Ok(playlist) } +/// The playlist that lists a subtitle file: the whole file as a single segment as long as the +/// presentation, which is valid for VOD. It is the same for every language, because it names the +/// file relative to its own location. +pub(crate) fn subtitle_playlist(presentation: Presentation<'_>) -> String { + let seconds = presentation.duration_seconds().max(1); + let version = presentation.version(); + format!( + "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:{seconds}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXTINF:{seconds}.000,\nsub.vtt?v={version}\n#EXT-X-ENDLIST\n" + ) +} + /// The I-frame playlist of the video track: one entry per keyframe, each its own one-sample /// fragment. `None` for an asset without video. pub(crate) fn iframe_playlist(presentation: Presentation<'_>) -> Result> { @@ -212,6 +233,23 @@ fn bits_per_second(bytes: u64, ticks: u64, timescale: u32) -> Option { .checked_div(ticks) } +/// One `#EXT-X-MEDIA:TYPE=SUBTITLES` line. `AUTOSELECT` follows `DEFAULT`, and `FORCED` marks +/// a track a player should show even when the viewer has not asked for subtitles. +fn write_subtitle_rendition(playlist: &mut String, subtitle: &Subtitle, version: &str) { + let flag = |value: bool| if value { "YES" } else { "NO" }; + let name = subtitle.label.replace('"', "'"); + writeln!( + playlist, + "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{name}\",LANGUAGE=\"{}\",DEFAULT={},AUTOSELECT={},FORCED={},URI=\"subtitles/{}/index.m3u8?v={version}\"", + subtitle.language, + flag(subtitle.default), + flag(subtitle.default || subtitle.forced), + flag(subtitle.forced), + subtitle.language + ) + .expect("writing to a String cannot fail"); +} + /// One `#EXT-X-MEDIA` line. Renditions are named by position, because the handler names encoders /// write (`SoundHandler`) say nothing to a viewer; the language is added when the file has one. fn write_audio_rendition(playlist: &mut String, track: &Track, index: usize, version: &str) { diff --git a/src/protocol/presentation.rs b/src/protocol/presentation.rs index 8388c80..7418db8 100644 --- a/src/protocol/presentation.rs +++ b/src/protocol/presentation.rs @@ -6,6 +6,7 @@ use crate::error::{Error, Result}; use crate::media::{Sample, Track, TrackKey, TrackKind}; use crate::segment::{SegmentPlan, TrackSegment}; +use crate::subtitle::Subtitle; /// Bits per second for one track, in the terms HLS and DASH declare them. #[derive(Debug, Clone, Copy)] @@ -20,6 +21,7 @@ pub(crate) struct Presentation<'a> { tracks: &'a [Track], plan: &'a SegmentPlan, version: &'a str, + subtitles: &'a [Subtitle], } impl<'a> Presentation<'a> { @@ -28,9 +30,28 @@ impl<'a> Presentation<'a> { tracks, plan, version, + subtitles: &[], } } + /// The same view with the asset's sidecar subtitles. + pub(crate) const fn with_subtitles(self, subtitles: &'a [Subtitle]) -> Self { + Self { subtitles, ..self } + } + + pub(crate) const fn subtitles(&self) -> &'a [Subtitle] { + self.subtitles + } + + /// The presentation's length in seconds, rounded up: the longest track. + pub(crate) fn duration_seconds(&self) -> u64 { + self.tracks + .iter() + .map(|track| track.duration.div_ceil(u64::from(track.timescale))) + .max() + .unwrap_or(0) + } + pub(crate) const fn tracks(&self) -> &'a [Track] { self.tracks } diff --git a/src/registry/mod.rs b/src/registry/mod.rs index ae32816..131682e 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -29,8 +29,11 @@ use crate::asset::PackagedAsset; use crate::config::{Config, LimitsConfig, is_valid_asset_id}; use crate::error::{Error, Result}; use crate::observability::metrics::{CacheEvent, Metrics, ResolverOutcome}; -use crate::resolver::{AssetLocation, AssetResolver, Resolution, ResolveError, ResolvedAsset}; +use crate::resolver::{ + AssetLocation, AssetResolver, Resolution, ResolveError, ResolvedAsset, SubtitleLocation, +}; use crate::source::LocationRefresher; +use crate::subtitle::Subtitle; use cache::LoadedCache; pub(crate) use opener::SourceOpener; @@ -546,6 +549,49 @@ impl AssetRegistry { } } + /// Fetches every sidecar subtitle file the mapper listed, within the configured limits. A file + /// that cannot be read fails the asset, so a viewer never gets a silently missing language. + async fn fetch_subtitles(&self, listed: &[SubtitleLocation]) -> Result> { + if listed.len() > self.limits.max_subtitles { + return Err(Error::InvalidMedia(format!( + "the mapper listed {} subtitles, more than limits.max_subtitles ({})", + listed.len(), + self.limits.max_subtitles + ))); + } + let mut total = 0u64; + let mut subtitles = Vec::with_capacity(listed.len()); + for entry in listed { + let data = self + .opener + .read_whole(&entry.location, self.limits.max_subtitle_bytes) + .await + .map_err(|error| match error { + // Only a bad file is named; an outage or a rejected location keeps its kind, so + // it is retried or reported as the mapper's fault and not as broken media. + Error::InvalidMedia(message) => { + Error::InvalidMedia(format!("subtitle `{}`: {message}", entry.language)) + } + other => other, + })?; + total = total.saturating_add(data.len() as u64); + if total > self.limits.max_subtitles_total_bytes { + return Err(Error::InvalidMedia(format!( + "subtitles exceed limits.max_subtitles_total_bytes ({})", + self.limits.max_subtitles_total_bytes + ))); + } + subtitles.push(Subtitle { + language: entry.language.clone(), + label: entry.label.clone(), + default: entry.default, + forced: entry.forced, + data, + }); + } + Ok(subtitles) + } + async fn load( self: &Arc, asset_id: &str, @@ -575,7 +621,14 @@ impl AssetRegistry { Arc::clone(&refresher) as Arc, ) .await?; - PackagedAsset::load(source, self.settings.segment_duration_ms, &self.limits).await + let subtitles = self.fetch_subtitles(&resolved.subtitles).await?; + PackagedAsset::load_with_subtitles( + source, + subtitles, + self.settings.segment_duration_ms, + &self.limits, + ) + .await } .await; if result.is_ok() { diff --git a/src/registry/opener.rs b/src/registry/opener.rs index c54d794..5cdde04 100644 --- a/src/registry/opener.rs +++ b/src/registry/opener.rs @@ -1,11 +1,17 @@ //! Turns a resolved location into an open media source. use std::path::{Path, PathBuf}; +use std::pin::Pin; use std::sync::Arc; +use bytes::Bytes; +use reqwest::Url; + use crate::error::{Error, Result}; use crate::resolver::AssetLocation; -use crate::source::{LocalMediaSource, LocationRefresher, MediaSourceKind, RemoteReader}; +use crate::source::{ + ByteRange, LocalMediaSource, LocationRefresher, MediaSourceKind, RemoteReader, +}; #[derive(Debug)] pub(crate) struct SourceOpener { @@ -45,6 +51,44 @@ impl SourceOpener { } } +impl SourceOpener { + /// Reads a whole small object, such as a subtitle file, refusing one over `max_bytes` before + /// any of it is read. It goes through the same confinement and remote-media rules as media. + pub(crate) async fn read_whole( + &self, + location: &AssetLocation, + max_bytes: u64, + ) -> Result { + let source = match location { + AssetLocation::File(_) => self.open(location, Arc::new(NoRefresh)).await?, + AssetLocation::Http(url) => { + MediaSourceKind::Http(Arc::new(self.remote.open(url.clone()).await?)) + } + }; + let length = source.len(); + if length > max_bytes { + return Err(Error::InvalidMedia(format!( + "file is {length} bytes, more than the limit of {max_bytes}" + ))); + } + source.read_range(ByteRange::new(0, length)).await + } +} + +/// For an object that has no signed URL to renew. +#[derive(Debug)] +struct NoRefresh; + +impl LocationRefresher for NoRefresh { + fn refresh(&self) -> Pin> + Send + '_>> { + Box::pin(async { + Err(Error::Upstream( + "this location cannot be refreshed".to_owned(), + )) + }) + } +} + /// Joins `path` to the media root, resolves symlinks, and requires a regular file that is still /// beneath the root, so no location can escape it. fn confine(root: &Path, path: &Path) -> Result { diff --git a/src/registry/tests.rs b/src/registry/tests.rs index 358e1c0..e60e55c 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -1164,3 +1164,243 @@ fn locations_that_differ_only_in_their_query_are_the_same_object() { assert!(File("a.mp4".into()).same_object(&File("a.mp4".into()))); assert!(!File("a.mp4".into()).same_object(&url("https://o.example/a.mp4"))); } + +// --------------------------------------------------------------------------------------------- +// Sidecar subtitles +// --------------------------------------------------------------------------------------------- + +fn text(bytes: &Bytes) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +#[tokio::test] +async fn subtitles_from_the_mapper_appear_in_both_protocols_and_are_served() { + let h = harness().await; + h.mapper.state.set( + "movie", + Answer::file("v1", "h264-aac.mp4") + .with_subtitle("en", "subtitles-en.vtt") + .with_subtitle("fr", "subtitles-fr.vtt"), + ); + + let (status, _, master) = fetch(&h.app, "/hls/movie/master.m3u8").await; + let master_text = text(&master); + let version = version_in(&master); + + assert_eq!(status, StatusCode::OK); + assert!(master_text.contains("TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"EN\",LANGUAGE=\"en\"")); + assert!(master_text.contains(&format!("URI=\"subtitles/fr/index.m3u8?v={version}\""))); + assert!(master_text.contains(",SUBTITLES=\"subs\""), "{master_text}"); + + let (status, headers, playlist) = fetch(&h.app, "/hls/movie/subtitles/en/index.m3u8").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers["content-type"], "application/vnd.apple.mpegurl"); + assert!(text(&playlist).contains(&format!("sub.vtt?v={version}"))); + + let uri = format!("/hls/movie/subtitles/en/sub.vtt?v={version}"); + let (status, headers, file) = fetch(&h.app, &uri).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers["content-type"], "text/vtt; charset=utf-8"); + assert!( + headers["cache-control"] + .to_str() + .unwrap() + .contains("immutable") + ); + assert!(text(&file).starts_with("WEBVTT")); + assert_eq!( + fetch( + &h.app, + &format!("/dash/movie/subtitles/en/sub.vtt?v={version}") + ) + .await + .2, + file + ); + + let (_, _, manifest) = fetch(&h.app, "/dash/movie/manifest.mpd").await; + let manifest = text(&manifest); + assert!( + manifest.contains("lang=\"fr\" mimeType=\"text/vtt\""), + "{manifest}" + ); + assert!(manifest.contains(&format!( + "subtitles/en/sub.vtt?v={version}" + ))); +} + +#[tokio::test] +async fn subtitle_urls_need_the_current_version_and_a_listed_language() { + let h = harness().await; + h.mapper.state.set( + "movie", + Answer::file("v1", "h264-aac.mp4").with_subtitle("en", "subtitles-en.vtt"), + ); + let version = version_in(&fetch(&h.app, "/hls/movie/master.m3u8").await.2); + + for uri in [ + "/hls/movie/subtitles/en/sub.vtt".to_owned(), + "/hls/movie/subtitles/en/sub.vtt?v=stale".to_owned(), + format!("/hls/movie/subtitles/de/sub.vtt?v={version}"), + "/hls/movie/subtitles/de/index.m3u8".to_owned(), + ] { + assert_eq!(status(&h.app, &uri).await, StatusCode::NOT_FOUND, "{uri}"); + } + let uri = format!("/hls/movie/subtitles/EN/sub.vtt?v={version}"); + assert_eq!( + status(&h.app, &uri).await, + StatusCode::OK, + "language matches without case" + ); +} + +#[tokio::test] +async fn an_asset_without_subtitles_is_unchanged() { + let h = harness().await; + h.mapper + .state + .set("movie", Answer::file("v1", "h264-aac.mp4")); + + let master = text(&fetch(&h.app, "/hls/movie/master.m3u8").await.2); + + assert!(!master.contains("SUBTITLES"), "{master}"); + assert!(!text(&fetch(&h.app, "/dash/movie/manifest.mpd").await.2).contains("text/vtt")); +} + +#[tokio::test] +async fn changing_a_caption_gives_the_asset_new_urls() { + let h = harness().await; + let mut first = Answer::file("v1", "h264-aac.mp4").with_subtitle("en", "subtitles-en.vtt"); + first.ttl_seconds = Some(0); + h.mapper.state.set("movie", first); + let old = version_in(&fetch(&h.app, "/hls/movie/master.m3u8").await.2); + + // The mapper changes its version when a caption changes, as its contract says it must. + let mut second = Answer::file("v2", "h264-aac.mp4").with_subtitle("en", "subtitles-fr.vtt"); + second.ttl_seconds = Some(0); + h.mapper.state.set("movie", second); + tokio::time::sleep(Duration::from_millis(40)).await; + let new = version_in(&fetch(&h.app, "/hls/movie/master.m3u8").await.2); + + assert_ne!(old, new); + assert_eq!( + status(&h.app, &format!("/hls/movie/subtitles/en/sub.vtt?v={old}")).await, + StatusCode::NOT_FOUND, + "the old URL stops resolving" + ); + let file = fetch(&h.app, &format!("/hls/movie/subtitles/en/sub.vtt?v={new}")) + .await + .2; + assert!(text(&file).contains("Bonjour")); +} + +#[tokio::test] +async fn a_bad_subtitle_fails_the_asset() { + for (path, expected) in [ + // Not WebVTT: the media is fine and the file is not. + ("subtitles-bad.srt", StatusCode::INTERNAL_SERVER_ERROR), + // Not there: the mapper pointed at something that does not exist. + ("missing.vtt", StatusCode::BAD_GATEWAY), + ] { + let h = harness().await; + h.mapper.state.set( + "movie", + Answer::file("v1", "h264-aac.mp4").with_subtitle("fr", path), + ); + + assert_eq!( + status(&h.app, "/hls/movie/master.m3u8").await, + expected, + "{path}" + ); + } +} + +#[tokio::test] +async fn subtitle_limits_are_enforced() { + let h = harness_with(|config| config.limits.max_subtitle_bytes = 20).await; + h.mapper.state.set( + "movie", + Answer::file("v1", "h264-aac.mp4").with_subtitle("en", "subtitles-en.vtt"), + ); + assert_ne!( + status(&h.app, "/hls/movie/master.m3u8").await, + StatusCode::OK, + "a file over max_subtitle_bytes fails the asset" + ); + + let h = harness_with(|config| config.limits.max_subtitles = 1).await; + h.mapper.state.set( + "movie", + Answer::file("v1", "h264-aac.mp4") + .with_subtitle("en", "subtitles-en.vtt") + .with_subtitle("fr", "subtitles-fr.vtt"), + ); + assert_ne!( + status(&h.app, "/hls/movie/master.m3u8").await, + StatusCode::OK, + "more files than max_subtitles fails the asset" + ); +} + +#[tokio::test] +async fn invalid_subtitle_entries_from_the_mapper_are_rejected() { + for entries in [ + // A language that is not a URL-safe tag. + r#"[{"language":"../x","location":{"type":"file","path":"subtitles-en.vtt"}}]"#, + // The same language twice, differing only in case. + r#"[{"language":"en","location":{"type":"file","path":"subtitles-en.vtt"}},{"language":"EN","location":{"type":"file","path":"subtitles-fr.vtt"}}]"#, + // Two defaults. + r#"[{"language":"en","default":true,"location":{"type":"file","path":"subtitles-en.vtt"}},{"language":"fr","default":true,"location":{"type":"file","path":"subtitles-fr.vtt"}}]"#, + // A path that escapes the media root. + r#"[{"language":"en","location":{"type":"file","path":"../secret.vtt"}}]"#, + // A remote host the policy does not allow. + r#"[{"language":"en","location":{"type":"http","url":"http://evil.example/x.vtt"}}]"#, + ] { + let h = harness().await; + *h.mapper.state.raw_body.lock().unwrap() = Some(format!( + r#"{{"asset_id":"movie","version":"v1","location":{{"type":"file","path":"h264-aac.mp4"}},"subtitles":{entries}}}"# + )); + assert_eq!( + status(&h.app, "/hls/movie/master.m3u8").await, + StatusCode::BAD_GATEWAY, + "{entries}" + ); + } +} + +#[tokio::test] +async fn cues_follow_an_asset_whose_timeline_was_shifted() { + let h = harness().await; + h.mapper.state.set( + "delayed", + Answer::file("v1", "h264-aac-video-delay.mp4").with_subtitle("en", "subtitles-en.vtt"), + ); + h.mapper.state.set( + "plain", + Answer::file("v1", "h264-aac.mp4").with_subtitle("en", "subtitles-en.vtt"), + ); + + let mut served = Vec::new(); + for asset in ["delayed", "plain"] { + let version = version_in(&fetch(&h.app, &format!("/hls/{asset}/master.m3u8")).await.2); + let uri = format!("/hls/{asset}/subtitles/en/sub.vtt?v={version}"); + served.push(text(&fetch(&h.app, &uri).await.2)); + } + + // The video starts 1.5 s late, so a cue authored at 0.5 s appears at 2.0 s. + assert!( + served[0].contains("00:00:02.000 --> 00:00:03.000 line:90%"), + "{}", + served[0] + ); + assert!( + served[0].contains("00:00:03.000 --> 00:00:04.000\nWorld"), + "{}", + served[0] + ); + assert!( + served[1].contains("00:00.500 --> 00:01.500 line:90%"), + "no shift, no change" + ); +} diff --git a/src/resolver/catalog.rs b/src/resolver/catalog.rs index 50cc0ac..0f52d35 100644 --- a/src/resolver/catalog.rs +++ b/src/resolver/catalog.rs @@ -29,6 +29,7 @@ impl StaticResolver { let path = self.assets.get(asset_id).ok_or(ResolveError::NotFound)?; Ok(Resolution::Resolved(ResolvedAsset { location: AssetLocation::File(path.clone()), + subtitles: Vec::new(), version: STATIC_VERSION.to_owned(), valid_until: Instant::now() + FOREVER, hard_expiry: None, diff --git a/src/resolver/mapper.rs b/src/resolver/mapper.rs index b7203ac..2eaae36 100644 --- a/src/resolver/mapper.rs +++ b/src/resolver/mapper.rs @@ -13,12 +13,13 @@ use time::format_description::well_known::Rfc3339; use tokio::time::sleep; use super::policy::{LocationPolicy, validate_relative_path}; -use super::{AssetLocation, Resolution, ResolveError, ResolvedAsset}; +use super::{AssetLocation, Resolution, ResolveError, ResolvedAsset, SubtitleLocation}; use crate::config::MapperConfig; use crate::config::Secret; use crate::observability::request_id; const MAX_VERSION_BYTES: usize = 256; +const MAX_LABEL_BYTES: usize = 128; const MAX_RETRY_AFTER: Duration = Duration::from_secs(1); #[derive(Debug)] @@ -38,6 +39,19 @@ struct Wire { ttl_seconds: Option, expires_at: Option, location: WireLocation, + #[serde(default)] + subtitles: Vec, +} + +#[derive(Debug, Deserialize)] +struct WireSubtitle { + language: String, + label: Option, + #[serde(default)] + default: bool, + #[serde(default)] + forced: bool, + location: WireLocation, } /// `serde` rejects an unknown `type`, which is how unknown location kinds are refused. @@ -222,6 +236,72 @@ impl HttpResolver { Duration::from_millis(requested.clamp(self.settings.min_ttl_ms, self.settings.max_ttl_ms)) } + /// A location, checked against the path rules or the remote-media policy. + fn interpret_location(&self, wire: &WireLocation) -> Result { + match wire { + WireLocation::File { path } => Ok(AssetLocation::File( + validate_relative_path(path).map_err(ResolveError::Rejected)?, + )), + WireLocation::Http { url } => { + let url = Url::parse(url) + .map_err(|_| ResolveError::Rejected("location URL is not valid".to_owned()))?; + self.policy + .check_url(&url) + .map_err(ResolveError::Rejected)?; + Ok(AssetLocation::Http(url)) + } + } + } + + fn interpret_subtitles( + &self, + wire: &[WireSubtitle], + ) -> Result, ResolveError> { + let reject = |message: String| Err(ResolveError::Rejected(message)); + let mut subtitles: Vec = Vec::with_capacity(wire.len()); + for entry in wire { + if !is_language_tag(&entry.language) { + return reject(format!( + "subtitle language `{}` is not a BCP 47 tag of letters, digits and hyphens", + entry.language.escape_default() + )); + } + if subtitles + .iter() + .any(|known| known.language.eq_ignore_ascii_case(&entry.language)) + { + return reject(format!( + "subtitle language `{}` is listed twice", + entry.language + )); + } + let label = entry + .label + .clone() + .unwrap_or_else(|| entry.language.clone()); + if label.is_empty() + || label.len() > MAX_LABEL_BYTES + || label.chars().any(char::is_control) + { + return reject(format!( + "subtitle `{}` has an empty, overlong, or control-character label", + entry.language + )); + } + subtitles.push(SubtitleLocation { + language: entry.language.clone(), + label, + default: entry.default, + forced: entry.forced, + location: self.interpret_location(&entry.location)?, + }); + } + if subtitles.iter().filter(|subtitle| subtitle.default).count() > 1 { + return reject("more than one subtitle is marked default".to_owned()); + } + Ok(subtitles) + } + /// Validates a `200` answer against the request and the location policy. fn interpret( &self, @@ -242,19 +322,8 @@ impl HttpResolver { { return reject("mapper version must be 1 to 256 visible ASCII characters"); } - let location = match &wire.location { - WireLocation::File { path } => { - AssetLocation::File(validate_relative_path(path).map_err(ResolveError::Rejected)?) - } - WireLocation::Http { url } => { - let url = Url::parse(url) - .map_err(|_| ResolveError::Rejected("location URL is not valid".to_owned()))?; - self.policy - .check_url(&url) - .map_err(ResolveError::Rejected)?; - AssetLocation::Http(url) - } - }; + let location = self.interpret_location(&wire.location)?; + let subtitles = self.interpret_subtitles(&wire.subtitles)?; let now = Instant::now(); let mut valid_until = now + self.ttl(wire.ttl_seconds, max_age_seconds); @@ -275,6 +344,7 @@ impl HttpResolver { } Ok(ResolvedAsset { location, + subtitles, version: wire.version, valid_until, hard_expiry, @@ -292,3 +362,13 @@ fn max_age(response: &Response) -> Option { .split(',') .find_map(|directive| directive.trim().strip_prefix("max-age=")?.parse().ok()) } + +/// A language tag as it can appear in a URL path: letters, digits, and single hyphens, starting +/// with a letter, at most 35 characters. +fn is_language_tag(tag: &str) -> bool { + tag.len() <= 35 + && tag + .split('-') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_alphanumeric())) + && tag.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) +} diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index e185945..6fe7f79 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -46,10 +46,23 @@ impl AssetLocation { } } +/// A sidecar `WebVTT` file the mapper attached to an asset. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SubtitleLocation { + /// A BCP 47 tag, unique within the asset (case-insensitively). It appears in URLs. + pub(crate) language: String, + pub(crate) label: String, + pub(crate) default: bool, + pub(crate) forced: bool, + pub(crate) location: AssetLocation, +} + /// A resolver's answer for one asset. #[derive(Debug, Clone)] pub(crate) struct ResolvedAsset { pub(crate) location: AssetLocation, + /// Sidecar subtitles, in the order the mapper listed them. + pub(crate) subtitles: Vec, /// Opaque change token: equal versions mean identical media. pub(crate) version: String, /// After this instant the answer must be revalidated before use. diff --git a/src/subtitle.rs b/src/subtitle.rs new file mode 100644 index 0000000..92653fb --- /dev/null +++ b/src/subtitle.rs @@ -0,0 +1,198 @@ +//! Sidecar `WebVTT` files: validation and timeline correction. +//! +//! A subtitle file is fetched once when its asset loads and held in memory. Packaging can move an +//! asset onto a shifted timeline (an edit list or a fragmented start time), so a cue authored +//! against the source would appear early or late; [`prepare`] adds that offset to every cue timing +//! line and leaves everything else in the file as it was. + +use std::fmt::Write; + +use bytes::Bytes; + +use crate::error::{Error, Result}; + +/// One subtitle file of an asset. Before [`prepare`] `data` is what was fetched; afterwards it is +/// what is served. +#[derive(Debug, Clone)] +pub(crate) struct Subtitle { + pub(crate) language: String, + pub(crate) label: String, + pub(crate) default: bool, + pub(crate) forced: bool, + pub(crate) data: Bytes, +} + +/// Validates `data` as `WebVTT` and moves every cue `offset_ms` later. +/// +/// The file must be UTF-8 and begin with `WEBVTT` (after an optional byte order mark). With no +/// offset the original bytes are returned untouched, but a cue timing line that cannot be read is +/// refused either way, so a bad file is caught when the asset loads and not when a viewer's +/// player meets it. +pub(crate) fn prepare(language: &str, data: &[u8], offset_ms: u64) -> Result { + let refuse = |reason: &str| Error::InvalidMedia(format!("subtitle `{language}`: {reason}")); + let text = std::str::from_utf8(data).map_err(|_| refuse("is not UTF-8"))?; + let body = text.strip_prefix('\u{feff}').unwrap_or(text); + let header_ok = body + .strip_prefix("WEBVTT") + .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t', '\n', '\r'])); + if !header_ok { + return Err(refuse("does not begin with WEBVTT")); + } + + let mut shifted = String::with_capacity(body.len() + body.len() / 8); + for line in body.split_inclusive(['\n', '\r']) { + let content = line.trim_end_matches(['\n', '\r']); + let terminator = &line[content.len()..]; + if content.contains("-->") { + let timing = shift_timing_line(content, offset_ms) + .ok_or_else(|| refuse("has a cue timing line that cannot be read"))?; + shifted.push_str(&timing); + } else { + shifted.push_str(content); + } + shifted.push_str(terminator); + } + if offset_ms == 0 { + Ok(Bytes::copy_from_slice(data)) + } else { + Ok(Bytes::from(shifted)) + } +} + +/// `start --> end settings`, with both times moved. `None` when it is not a well-formed timing. +fn shift_timing_line(line: &str, offset_ms: u64) -> Option { + let (start, rest) = line.split_once("-->")?; + let start = start.trim(); + let rest = rest.trim_start(); + let (end, settings) = rest + .split_once([' ', '\t']) + .map_or((rest, ""), |(end, settings)| (end, settings)); + let start = parse_timestamp(start)?.checked_add(offset_ms)?; + let end = parse_timestamp(end)?.checked_add(offset_ms)?; + let mut out = String::new(); + write!( + out, + "{} --> {}", + format_timestamp(start), + format_timestamp(end) + ) + .ok()?; + if !settings.is_empty() { + out.push(' '); + out.push_str(settings.trim_start()); + } + Some(out) +} + +/// `[hh:]mm:ss.ttt` in milliseconds. Hours may have more than two digits. +fn parse_timestamp(text: &str) -> Option { + let (clock, millis) = text.split_once('.')?; + if millis.len() != 3 || !millis.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let fields = clock.split(':').collect::>(); + let (hours, minutes, seconds) = match fields.as_slice() { + [minutes, seconds] => ("0", *minutes, *seconds), + [hours, minutes, seconds] => (*hours, *minutes, *seconds), + _ => return None, + }; + let number = |field: &str, at_least: usize, at_most: usize| -> Option { + (field.bytes().all(|byte| byte.is_ascii_digit()) + && (at_least..=at_most).contains(&field.len())) + .then(|| field.parse().ok())? + }; + let hours = if fields.len() == 3 { + number(hours, 2, 10)? + } else { + 0 + }; + let minutes = number(minutes, 2, 2).filter(|minutes| *minutes < 60)?; + let seconds = number(seconds, 2, 2).filter(|seconds| *seconds < 60)?; + hours + .checked_mul(3_600_000)? + .checked_add(minutes * 60_000)? + .checked_add(seconds * 1000)? + .checked_add(millis.parse::().ok()?) +} + +fn format_timestamp(milliseconds: u64) -> String { + let hours = milliseconds / 3_600_000; + let minutes = milliseconds / 60_000 % 60; + let seconds = milliseconds / 1000 % 60; + format!( + "{hours:02}:{minutes:02}:{seconds:02}.{:03}", + milliseconds % 1000 + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn shifted(text: &str, offset_ms: u64) -> String { + String::from_utf8( + prepare("en", text.as_bytes(), offset_ms) + .expect("valid") + .to_vec(), + ) + .expect("UTF-8") + } + + #[test] + fn every_cue_moves_and_the_rest_of_the_file_does_not() { + let file = "WEBVTT - title\n\nNOTE a comment\n\nintro\n00:01.000 --> 00:02.500 line:90% align:start\nHello\n\n01:00:00.000 --> 01:00:01.000\nHour cue\n"; + + let out = shifted(file, 2500); + + assert_eq!( + out, + "WEBVTT - title\n\nNOTE a comment\n\nintro\n00:00:03.500 --> 00:00:05.000 line:90% align:start\nHello\n\n01:00:02.500 --> 01:00:03.500\nHour cue\n" + ); + } + + #[test] + fn the_shift_carries_across_seconds_minutes_and_hours() { + let out = shifted("WEBVTT\n\n59:59.900 --> 59:59.999\nx\n", 200); + + assert!(out.contains("01:00:00.100 --> 01:00:00.199"), "{out}"); + } + + #[test] + fn no_offset_returns_the_original_bytes() { + let file = "\u{feff}WEBVTT\r\n\r\n00:01.000 --> 00:02.000\r\nHi\r\n"; + + assert_eq!( + prepare("en", file.as_bytes(), 0).expect("valid"), + file.as_bytes() + ); + } + + #[test] + fn line_endings_survive_a_shift() { + let out = shifted("WEBVTT\r\n\r\n00:01.000 --> 00:02.000\r\nHi\r\n", 1000); + + assert_eq!(out, "WEBVTT\r\n\r\n00:00:02.000 --> 00:00:03.000\r\nHi\r\n"); + } + + #[test] + fn bad_files_are_refused_and_name_the_language() { + for (data, needle) in [ + (&b"\xff\xfe"[..], "not UTF-8"), + (b"", "WEBVTT"), + (b"WEBVTTX\n", "WEBVTT"), + (b"1\n00:01,000 --> 00:02,000\nsrt\n", "WEBVTT"), + (b"WEBVTT\n\n00:01.000 --> nonsense\nx\n", "timing"), + (b"WEBVTT\n\n00:61.000 --> 00:62.000\nx\n", "timing"), + (b"WEBVTT\n\n00:01.00 --> 00:02.000\nx\n", "timing"), + ( + b"WEBVTT\n\n99999999999999999999:00:00.000 --> 00:02.000\nx\n", + "timing", + ), + ] { + let error = prepare("fr", data, 0) + .expect_err("should be refused") + .to_string(); + assert!(error.contains("`fr`") && error.contains(needle), "{error}"); + } + } +} diff --git a/src/testutil.rs b/src/testutil.rs index 6dbfb6c..29212a7 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -53,15 +53,27 @@ pub(crate) struct Answer { pub(crate) location: Value, pub(crate) ttl_seconds: Option, pub(crate) expires_at: Option, + pub(crate) subtitles: Vec, } impl Answer { + /// Adds a `file` subtitle entry. + pub(crate) fn with_subtitle(mut self, language: &str, path: &str) -> Self { + self.subtitles.push(json!({ + "language": language, + "label": language.to_uppercase(), + "location": { "type": "file", "path": path }, + })); + self + } + pub(crate) fn file(version: &str, path: &str) -> Self { Self { version: version.to_owned(), location: json!({ "type": "file", "path": path }), ttl_seconds: Some(300), expires_at: None, + subtitles: Vec::new(), } } @@ -71,6 +83,7 @@ impl Answer { location: json!({ "type": "http", "url": url }), ttl_seconds: Some(300), expires_at: None, + subtitles: Vec::new(), } } } @@ -190,6 +203,9 @@ async fn mapper_asset( "version": answer.version, "location": answer.location, }); + if !answer.subtitles.is_empty() { + body["subtitles"] = json!(answer.subtitles); + } if let Some(ttl) = answer.ttl_seconds { body["ttl_seconds"] = json!(ttl); } diff --git a/tests/fixtures/generate-variants.sh b/tests/fixtures/generate-variants.sh index 131ca24..9b0272b 100755 --- a/tests/fixtures/generate-variants.sh +++ b/tests/fixtures/generate-variants.sh @@ -30,6 +30,15 @@ ffmpeg_quiet \ -movflags +faststart \ "$fixture_dir/h264-aac-audio-delay.mp4" +# Video delayed by a second and a half: the video track gets a leading empty edit, so the whole +# presentation sits on a shifted timeline that sidecar subtitles must follow. +ffmpeg_quiet \ + -itsoffset 1.5 -f lavfi -i "$video" -f lavfi -i "$tone_a" \ + -c:v libx264 -pix_fmt yuv420p -preset medium -g 30 -keyint_min 30 -sc_threshold 0 -bf 2 \ + -c:a aac -profile:a aac_low -b:a 96k \ + -movflags +faststart \ + "$fixture_dir/h264-aac-video-delay.mp4" + # Two audio tracks with different languages. ffmpeg_quiet \ -f lavfi -i "$video" -f lavfi -i "$tone_a" -f lavfi -i "$tone_b" \ diff --git a/tests/fixtures/h264-aac-video-delay.mp4 b/tests/fixtures/h264-aac-video-delay.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..c387aac81c209e788a9cc01dcf5d23bb4850d092 GIT binary patch literal 140567 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�nYr( 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 00:00:02,000 +not webvtt diff --git a/tests/fixtures/subtitles-en.vtt b/tests/fixtures/subtitles-en.vtt new file mode 100644 index 0000000..1d9906a --- /dev/null +++ b/tests/fixtures/subtitles-en.vtt @@ -0,0 +1,7 @@ +WEBVTT + +00:00.500 --> 00:01.500 line:90% +Hello + +00:01.500 --> 00:02.500 +World diff --git a/tests/fixtures/subtitles-fr.vtt b/tests/fixtures/subtitles-fr.vtt new file mode 100644 index 0000000..2cb9f7f --- /dev/null +++ b/tests/fixtures/subtitles-fr.vtt @@ -0,0 +1,4 @@ +WEBVTT + +00:00.500 --> 00:01.500 +Bonjour From ba56bb356d8f32f0833fd9cbdde0f1705a4cbc2a Mon Sep 17 00:00:00 2001 From: includeamin Date: Mon, 21 Sep 2026 19:10:17 +0200 Subject: [PATCH 6/7] test(metadata): give each test its own source file so parallel runs stop clobbering each other Signed-off-by: includeamin --- src/source/metadata.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/source/metadata.rs b/src/source/metadata.rs index cfd85f5..3d58508 100644 --- a/src/source/metadata.rs +++ b/src/source/metadata.rs @@ -613,11 +613,16 @@ mod tests { bytes } - /// Writes `bytes` under `target/` and opens it as a source. + /// Writes `bytes` under `target/` and opens it as a source. Tests run in parallel and several + /// use the same names, so each call gets a file of its own: one test rewriting a file while + /// another reads it made `every_kind_of_wrong_sidx_falls_back_to_the_sequential_walk` fail now + /// and then. fn source(name: &str, bytes: &[u8]) -> MediaSourceKind { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/metadata-tests"); std::fs::create_dir_all(&directory).unwrap(); - let path = directory.join(name); + let unique = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = directory.join(format!("{}-{unique}-{name}", std::process::id())); std::fs::write(&path, bytes).unwrap(); MediaSourceKind::Local(Arc::new(LocalMediaSource::open(path).unwrap())) } From 7d6b30f5142ab8fd3002d000c719f96398b78b9c Mon Sep 17 00:00:00 2001 From: includeamin Date: Mon, 21 Sep 2026 19:10:17 +0200 Subject: [PATCH 7/7] fix(subtitles): shift cues by the shared edit-list offset, not one track's delay Signed-off-by: includeamin --- docs/mapper-api.md | 2 +- ...006-trick-play-subtitles-and-renditions.md | 4 +- src/asset.rs | 19 +++------ src/media/index.rs | 4 ++ src/mp4/edit.rs | 37 ++++++++++++----- src/mp4/parser.rs | 2 + src/registry/tests.rs | 40 +++++++++++-------- 7 files changed, 63 insertions(+), 45 deletions(-) diff --git a/docs/mapper-api.md b/docs/mapper-api.md index d6be311..30cbdeb 100644 --- a/docs/mapper-api.md +++ b/docs/mapper-api.md @@ -134,7 +134,7 @@ An answer may attach WebVTT subtitle files to the asset: 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. -If the media's timeline was moved (an edit list or a late start), the server adds the same offset to every cue, so cues authored against the file's own clock stay in step with the picture. Nothing else in the file changes. +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. diff --git a/docs/technical-design/0006-trick-play-subtitles-and-renditions.md b/docs/technical-design/0006-trick-play-subtitles-and-renditions.md index 5b8c511..756eaf0 100644 --- a/docs/technical-design/0006-trick-play-subtitles-and-renditions.md +++ b/docs/technical-design/0006-trick-play-subtitles-and-renditions.md @@ -62,7 +62,7 @@ Each I-frame resource, prefixed with the init segment, must decode to exactly on ## 2. Sidecar WebVTT subtitles -> **Implemented** as designed, with two refinements: an `http` subtitle origin must support ranged requests (it is opened like media, so it gets the same `[remote_media]` and redirect protection), and the size and count limits are `limits.max_subtitle_bytes`, `limits.max_subtitles_total_bytes`, and `limits.max_subtitles`. The version covers subtitle content; a mapper must still change its own `version` when a caption changes, because that is what triggers a reload. Not yet checked in a browser. +> **Implemented** as designed, with two refinements: an `http` subtitle origin must support ranged requests (it is opened like media, so it gets the same `[remote_media]` and redirect protection), and the size and count limits are `limits.max_subtitle_bytes`, `limits.max_subtitles_total_bytes`, and `limits.max_subtitles`. The version covers subtitle content; a mapper must still change its own `version` when a caption changes, because that is what triggers a reload. Checked in headless Chrome with hls.js and dash.js: cues appear at the right times, including on a file whose edit lists shift the timeline by 67 ms. The shift is the edit lists' shared offset `O`, not a track's own delay (a late-starting video is already part of the presentation the cues were written against), and a fragmented file is not shifted. Not checked in Safari. ### Mapper answer @@ -80,7 +80,7 @@ An optional `subtitles` list, each entry: The file is fetched when the asset loads and is held in memory, so requests never touch the origin: - **Validation.** UTF-8, at most `limits.max_subtitle_bytes` (default 2 MiB) each and a limit in total, and it must begin with `WEBVTT`. Anything else fails the asset load with a message naming the language. -- **Timeline correction.** An asset with an edit list or a fragmented start time is moved onto a shifted timeline (see [TDD 0004](0004-broader-mp4-input-support.md)), so a cue authored against the source would appear early or late by that offset. Cue timing lines are shifted by the asset's timeline offset when the file is served. Everything else in the file is passed through unchanged. +- **Timeline correction.** An asset whose edit lists trim encoder delay is served on a timeline `O` later than the source's clock (see [TDD 0004](0004-broader-mp4-input-support.md)), so a cue authored against the source would appear early by `O`. Cue timing lines are shifted by `O` when the asset loads. A track's own delay is not part of `O`, and a fragmented file, whose timeline starts at zero, is not shifted. Everything else in the file is passed through unchanged. - **Version.** The asset version covers the subtitle content, so changing a caption gives new URLs. ### Output diff --git a/src/asset.rs b/src/asset.rs index ec03aa7..20aff76 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -5,7 +5,7 @@ use bytes::Bytes; use crate::config::LimitsConfig; use crate::error::{Error, Result}; -use crate::media::{MediaIndex, Sample, Track, TrackKey, TrackKind}; +use crate::media::{MediaIndex, Sample, Track, TrackKey}; use crate::mp4::ParsedMedia; use crate::protocol::{Presentation, dash, hls}; use crate::segment::{SegmentPlan, TrackSegment}; @@ -338,20 +338,11 @@ impl RenderedManifests { /// revision into the version gives such a build new URLs instead. const FORMAT_REVISION: u32 = 2; -/// Validates each subtitle file and moves its cues onto the asset's timeline: by the offset the -/// reference track was shifted by, which is the video track, or the first audio track without one. +/// Validates each subtitle file and moves its cues onto the asset's timeline, by the offset the +/// edit lists were resolved with. A track's own delay is not part of it: that is already in the +/// presentation the cues were written against. fn prepare_subtitles(index: &MediaIndex, subtitles: Vec) -> Result> { - let reference = index - .tracks - .iter() - .find(|track| track.kind == TrackKind::Video) - .or_else(|| index.tracks.first()); - let offset_ms = reference.map_or(0, |track| { - (u128::from(track.timeline_shift) * 1000 + u128::from(track.timescale) / 2) - / u128::from(track.timescale) - }); - let offset_ms = u64::try_from(offset_ms) - .map_err(|_| Error::InvalidMedia("timeline offset overflow".to_owned()))?; + let offset_ms = index.presentation_offset_ms; subtitles .into_iter() .map(|mut subtitle| { diff --git a/src/media/index.rs b/src/media/index.rs index af0e703..33f039e 100644 --- a/src/media/index.rs +++ b/src/media/index.rs @@ -7,6 +7,10 @@ pub(crate) struct MediaIndex { pub(crate) source: SourceIdentity, pub(crate) movie_timescale: u32, pub(crate) duration: u64, + /// How much later than the source's own clock every served timestamp is, in milliseconds. It + /// is the shared offset the edit lists were resolved with; zero for a file without edits and + /// for a fragmented file, whose timeline simply starts at zero. + pub(crate) presentation_offset_ms: u64, pub(crate) tracks: Vec, /// Tracks in the file that are not packaged, with the reason, so the registry can log them. pub(crate) skipped_tracks: Vec, diff --git a/src/mp4/edit.rs b/src/mp4/edit.rs index f99c75d..220f74b 100644 --- a/src/mp4/edit.rs +++ b/src/mp4/edit.rs @@ -129,6 +129,31 @@ fn unsupported(track_id: u32, detail: impl std::fmt::Display) -> Error { Error::Unsupported(format!("track {track_id}: {detail}")) } +/// The shared offset `O` as a fraction of a second: `(numerator, denominator)`, denominator +/// positive. Served timestamps are presentation timestamps plus `O`. +fn shared_offset(edits: &[(TrackEdit, u32)], movie_timescale: u32) -> (i128, i128) { + let movie = i128::from(movie_timescale); + let mut offset = (0i128, 1i128); + for (edit, timescale) in edits { + let timescale = i128::from(*timescale); + let numerator = i128::from(edit.media_time) * movie - i128::from(edit.delay) * timescale; + let denominator = timescale * movie; + // numerator/denominator > offset.0/offset.1, without dividing. + if numerator * offset.1 > offset.0 * denominator { + offset = (numerator, denominator); + } + } + offset +} + +/// `O` in milliseconds, rounded to the nearest: how far later than the source's own clock every +/// timestamp in the served presentation is. Sidecar subtitles are authored against the source's +/// clock, so their cues move by this much. +pub(super) fn shared_offset_millis(edits: &[(TrackEdit, u32)], movie_timescale: u32) -> u64 { + let (numerator, denominator) = shared_offset(edits, movie_timescale); + u64::try_from((2 * numerator * 1000 + denominator) / (2 * denominator)).unwrap_or(0) +} + /// One shift per input, in that track's ticks, from the shared offset `O`: /// /// ```text @@ -142,17 +167,7 @@ pub(super) fn timeline_shifts( movie_timescale: u32, ) -> Result> { let movie = i128::from(movie_timescale); - // O as a fraction: (numerator, denominator), denominator positive. - let mut offset = (0i128, 1i128); - for (edit, timescale) in edits { - let timescale = i128::from(*timescale); - let numerator = i128::from(edit.media_time) * movie - i128::from(edit.delay) * timescale; - let denominator = timescale * movie; - // numerator/denominator > offset.0/offset.1, without dividing. - if numerator * offset.1 > offset.0 * denominator { - offset = (numerator, denominator); - } - } + let offset = shared_offset(edits, movie_timescale); edits .iter() .map(|(edit, timescale)| { diff --git a/src/mp4/parser.rs b/src/mp4/parser.rs index 9f97544..ca87d8a 100644 --- a/src/mp4/parser.rs +++ b/src/mp4/parser.rs @@ -117,6 +117,7 @@ fn parse_metadata( .map(|(edit, track)| (*edit, track.timescale)) .collect::>(); let shifts = edit::timeline_shifts(×cales, moov.movie_timescale)?; + let presentation_offset_ms = edit::shared_offset_millis(×cales, moov.movie_timescale); for ((track, edit), shift) in tracks.iter_mut().zip(edits).zip(shifts) { edit::apply(track, edit, shift)?; } @@ -136,6 +137,7 @@ fn parse_metadata( source: identity, movie_timescale: moov.movie_timescale, duration, + presentation_offset_ms, tracks, skipped_tracks, fragmentation: moov.fragmented.then(|| crate::media::Fragmentation { diff --git a/src/registry/tests.rs b/src/registry/tests.rs index e60e55c..e1d3c06 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -1370,37 +1370,43 @@ async fn invalid_subtitle_entries_from_the_mapper_are_rejected() { } #[tokio::test] -async fn cues_follow_an_asset_whose_timeline_was_shifted() { +async fn cues_follow_the_shared_offset_of_the_edit_lists_and_nothing_else() { let h = harness().await; - h.mapper.state.set( - "delayed", - Answer::file("v1", "h264-aac-video-delay.mp4").with_subtitle("en", "subtitles-en.vtt"), - ); - h.mapper.state.set( - "plain", - Answer::file("v1", "h264-aac.mp4").with_subtitle("en", "subtitles-en.vtt"), - ); + for (asset, file) in [ + // The edit lists trim encoder delay, so everything is served 66.7 ms later than the source. + ("edited", "h264-aac-default-edits.mp4"), + // The video starts 1.5 s in, which the presentation the cues were written against already + // contains: no shift, or the cues would be late by that much. + ("delayed", "h264-aac-video-delay.mp4"), + ("plain", "h264-aac.mp4"), + ] { + h.mapper.state.set( + asset, + Answer::file("v1", file).with_subtitle("en", "subtitles-en.vtt"), + ); + } let mut served = Vec::new(); - for asset in ["delayed", "plain"] { + for asset in ["edited", "delayed", "plain"] { let version = version_in(&fetch(&h.app, &format!("/hls/{asset}/master.m3u8")).await.2); let uri = format!("/hls/{asset}/subtitles/en/sub.vtt?v={version}"); served.push(text(&fetch(&h.app, &uri).await.2)); } - // The video starts 1.5 s late, so a cue authored at 0.5 s appears at 2.0 s. assert!( - served[0].contains("00:00:02.000 --> 00:00:03.000 line:90%"), + served[0].contains("00:00:00.567 --> 00:00:01.567 line:90%"), "{}", served[0] ); assert!( - served[0].contains("00:00:03.000 --> 00:00:04.000\nWorld"), + served[0].contains("00:00:01.567 --> 00:00:02.567\nWorld"), "{}", served[0] ); - assert!( - served[1].contains("00:00.500 --> 00:01.500 line:90%"), - "no shift, no change" - ); + for unchanged in &served[1..] { + assert!( + unchanged.contains("00:00.500 --> 00:01.500 line:90%"), + "{unchanged}" + ); + } }