From 043d5842d5e2e0cccd43ba1d3ca6ef7b26bce2d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:59:20 -0700 Subject: [PATCH 01/34] build(deps-dev): bump the bun group across 1 directory with 2 updates (#1903) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- bun.lock | 38 +++++++++++++++++++------------------- doc/package.json | 2 +- package.json | 2 +- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/bun.lock b/bun.lock index 07ddccb22..13f5fb717 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "moq", "devDependencies": { - "@biomejs/biome": "^2.4.16", + "@biomejs/biome": "^2.5.0", "concurrently": "^10.0.3", "publint": "^0.3.21", "remark-cli": "^12.0.1", @@ -59,7 +59,7 @@ "version": "0.1.0", "devDependencies": { "vitepress": "^1.6.4", - "wrangler": "^4.99.0", + "wrangler": "^4.101.0", }, }, "js/clock": { @@ -364,37 +364,37 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.0", "@biomejs/cli-darwin-x64": "2.5.0", "@biomejs/cli-linux-arm64": "2.5.0", "@biomejs/cli-linux-arm64-musl": "2.5.0", "@biomejs/cli-linux-x64": "2.5.0", "@biomejs/cli-linux-x64-musl": "2.5.0", "@biomejs/cli-win32-arm64": "2.5.0", "@biomejs/cli-win32-x64": "2.5.0" }, "bin": { "biome": "bin/biome" } }, "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260609.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-AK8tYLQm+8BqQMzjZ55ZfuhfIm1eCkj+Ykxz6kWXojdACwjjU03MrwdM9fBDdgzU3upXOs4e1scOFHySlfVQjA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260616.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8QaDRQABkwkwoeviNiyScol7EQgXfGsPNSyUn52GiXObthY4XPiokoJsgDSDNcAelHjEvDLmdvQBHPK8YvGn4A=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260609.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4kKXfr7ZHU6xQ/R9ShdSuj1A1bEouoRcHzUWdjnuMPBlRsAAVanlxAVYISotFUulLEinayOpRFbhpsfwzrpSSw=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260616.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xEhiZQ62CBJ+vyKSmM13rkK/wB1kLP5sKFkF3+P+3R/c2bmnSG3Vcd5FfXUu9V0PdC+KlR02nByvZjqEw2N6Ag=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260609.1", "", { "os": "linux", "cpu": "x64" }, "sha512-T2Ebir2OPHAvvZ0HUh5mi1lN8q30sVi4lf7LIpc28AHoWtoOmJ0jA5AJK4IYJm1MKEbBldq+QsckaHOCQFmRpQ=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260616.1", "", { "os": "linux", "cpu": "x64" }, "sha512-p5laSYPiRUMHaLkneaZ9ZfIkNpmEnGFwgYmXtfcHJutTfEd8o3IBnsUVRSbPL+phcshKqmapLsQSxDEX6WSFfA=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260609.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-INfcYoSsKqEIvPL69/3RkqYoP8WUR0VEN6loWN/3tekXLoJrVOj3E5NjIetsdS8MJN6zc3st/ae4bMuWRRzoDg=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260616.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-XQ7GonEl8ORvbz5fhe8Eyw2t/j09Li0KbXJxaldA318E+syF+PPTc4IRQudgqPWzzdzkH5nF7PuMOGySLSjFFw=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260609.1", "", { "os": "win32", "cpu": "x64" }, "sha512-EWhfxKI1aqUr7S8xuGxgmRCumEzB8iSsCIz6oEqJN+3pZuW3EWiKDGFW4EY1BmwNINLW1eO5VMGYb8Fj6FVYxA=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260616.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RaDVF9bSbPiPTq6vHYrgnv1TcQEcYnOr0WB3hWJ4yg2fBfpi2ygU6cYPuFeDwyFE9aPW5S6FBAkNmpKYueK4DQ=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -1280,7 +1280,7 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "miniflare": ["miniflare@4.20260609.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.24.8", "workerd": "1.20260609.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-4ZfNh9ACDa/mKKQvTSO2vigyQS2MB7dEU02KRPle4FqL7S6nek+2Fq6WGzazZbt1OORYgb4OGVLnOCx+My2NNA=="], + "miniflare": ["miniflare@4.20260616.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.24.8", "workerd": "1.20260616.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-cEpzoNgSWjedzYmhJvttUPmL4Jk6nSzzeNNi118T5zwnmYP9fnM8UXwFU/Qa/1qoQ4SzGqtM1Q7tinHvHvIGtw=="], "minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="], @@ -1684,9 +1684,9 @@ "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "workerd": ["workerd@1.20260609.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260609.1", "@cloudflare/workerd-darwin-arm64": "1.20260609.1", "@cloudflare/workerd-linux-64": "1.20260609.1", "@cloudflare/workerd-linux-arm64": "1.20260609.1", "@cloudflare/workerd-windows-64": "1.20260609.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-KF/Y/8f4VoXCk87NuU6RqmO0X5fdzcrxU3XzAgoPUpnH9t1ZyzRgX1O/9sJvjItxroCBTEBzKssda02Dz9i6BA=="], + "workerd": ["workerd@1.20260616.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260616.1", "@cloudflare/workerd-darwin-arm64": "1.20260616.1", "@cloudflare/workerd-linux-64": "1.20260616.1", "@cloudflare/workerd-linux-arm64": "1.20260616.1", "@cloudflare/workerd-windows-64": "1.20260616.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-aRGWYxviSjYZwyu97pCr5GyJ9ObpgmNcfZZs3/o+kG7Wz3SBTqA8d8uhNueY5u7ADeUp2ibJvK6mXkFLrUmPgg=="], - "wrangler": ["wrangler@4.99.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260609.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260609.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260609.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-i7GA2mZETTyq3ljWdEzM908FjLaMWZ1AaAHKaOJ8pFA/tonf2VqIWDyBGzKleIVBbNQxOTIY2wnbv0iaK3rC6g=="], + "wrangler": ["wrangler@4.101.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260616.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260616.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260616.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-dZDDiRcT7MiA09lBDxWKmiL/iybEZ+SZe3IZmnVx1m1n1DOo730vOY5SeO7z9xFK8a/+vhGKDYB8mDXrvzEr5g=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/doc/package.json b/doc/package.json index d7292dc3e..bd441700d 100644 --- a/doc/package.json +++ b/doc/package.json @@ -11,6 +11,6 @@ }, "devDependencies": { "vitepress": "^1.6.4", - "wrangler": "^4.99.0" + "wrangler": "^4.101.0" } } diff --git a/package.json b/package.json index 68b7dbcdb..8f8dd49b2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "moq", "version": "0.0.0", "devDependencies": { - "@biomejs/biome": "^2.4.16", + "@biomejs/biome": "^2.5.0", "concurrently": "^10.0.3", "publint": "^0.3.21", "remark-cli": "^12.0.1", From a7c937ece944c92bc331a3197f590d39504c8e83 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 24 Jun 2026 12:04:48 -0700 Subject: [PATCH 02/34] feat(hang): compressed catalog track (catalog.json.z) (#1904) Co-authored-by: Claude Opus 4.8 --- CLAUDE.md | 1 + doc/bin/cli.md | 8 +++ doc/concept/layer/hang.md | 9 ++++ doc/lib/js/@moq/watch.md | 2 +- js/hang/src/catalog/consumer.ts | 23 +++++++-- js/hang/src/catalog/format.ts | 6 +++ js/json/src/producer.test.ts | 24 +++++++++ js/json/src/producer.ts | 10 +++- js/publish/src/broadcast.ts | 9 +++- js/watch/src/broadcast.ts | 21 ++++---- rs/hang/src/catalog/root.rs | 14 +++++ rs/moq-cli/src/subscribe.rs | 6 ++- rs/moq-mux/src/catalog/format.rs | 19 ++++++- rs/moq-mux/src/catalog/hang/consumer.rs | 13 ++++- rs/moq-mux/src/catalog/producer.rs | 69 ++++++++++++++++++++++--- rs/moq-mux/src/container/source.rs | 4 ++ 16 files changed, 212 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6cd12d5c0..40185e245 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,7 @@ Favor composable building blocks over one-off functions. A handful of orthogonal Then future-proof what you do expose so additions don't force a breaking change: - **Config structs consumers construct**: add `#[non_exhaustive]` and a `Default` or constructor. New optional fields then stay additive (callers build via `default()`/`new()` + field set, not struct literals). Prefer adding a field to an existing `#[non_exhaustive]` config over adding a function parameter. +- **Take an options struct/object, not positional parameters, whenever a function or constructor could plausibly gain more knobs later.** A single `Config`/options bag (Rust struct, TS interface) lets you add fields without changing the signature; positional params force a breaking change (or an awkward `(track, undefined, opts)` call) the moment a second option shows up. Reach for it even when there's only one option today: a lone `compression: bool` arg is a future breaking change waiting to happen, whereas `Config { compression }` absorbs the next field for free. This applies in both languages, not just where `#[non_exhaustive]` does. - **Public enums that may gain variants**: add `#[non_exhaustive]` so external `match`es keep compiling. - **Name by role, not by today's only implementation** (`capture::Config`, `publish_capture`, not `CameraConfig`/`publish_camera`) so a second implementation slots in without a rename. Don't bundle generic options under a specific-case name. - **Namespace with modules; keep type names short.** Split a growing crate into role modules (`capture`, `encode`, `decode`) and let each own short, unprefixed names. The module already supplies the prefix, so `encode::Config` beats `EncoderConfig` and `encode::Producer` beats `VideoProducer`. But don't nest a module whose name echoes its main type: `encode::encoder::Encoder` stutters; re-export the type flat so it reads `encode::Encoder`. Re-export the public types at the role-module level (`pub use encoder::{Encoder, Config}`) and keep the file-level module (`mod encoder`) private. diff --git a/doc/bin/cli.md b/doc/bin/cli.md index b5085c77b..8713dd592 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -151,6 +151,14 @@ Subscribe (`--format`): - `ts` - MPEG-TS - `flv` - FLV / RTMP (H.264 video, AAC audio) +`subscribe` also takes `--catalog` to pick which catalog track to read for track +discovery. When omitted, it's auto-detected from the broadcast name suffix +(`.hang` -> `hang`, `.msf` -> `msf`), falling back to `hang`: + +- `hang` - the `catalog.json` JSON catalog (default) +- `hangz` - the DEFLATE-compressed `catalog.json.z` catalog (opt-in; shares the `.hang` suffix and is never auto-detected) +- `msf` - the MSF `catalog` track + ### MPEG-TS Ingest an MPEG-TS stream from FFmpeg and play one back out: diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md index 327b32449..fde480738 100644 --- a/doc/concept/layer/hang.md +++ b/doc/concept/layer/hang.md @@ -46,6 +46,15 @@ Here is Big Buck Bunny's `catalog.json` as of 2026-02-02: } ``` +### Compression + +The catalog is published on two tracks with identical content: `catalog.json` (plain JSON) and `catalog.json.z` (the same JSON, DEFLATE-compressed per group). +A publisher always serves both; a consumer reads whichever it prefers and defaults to the uncompressed `catalog.json`. + +The compression is the group-scoped `deflate-raw` ([RFC 1951](https://www.rfc-editor.org/rfc/rfc1951.html)) stream used by `@moq/json` / `moq-json`, interoperable between the browser and native. +To read the compressed track, opt in explicitly: pass `--catalog hangz` to `moq-cli subscribe`, `CatalogFormat::HangZ` in Rust, or `catalogFormat: "hangz"` to `@moq/watch`. +The `.hang` broadcast suffix is unchanged: the compressed track is an extra track on the same broadcast, not a different broadcast name. + ### Audio [See the latest schema](https://github.com/moq-dev/moq/blob/main/js/hang/src/catalog/audio.ts). diff --git a/doc/lib/js/@moq/watch.md b/doc/lib/js/@moq/watch.md index 190a66fc4..2e2678459 100644 --- a/doc/lib/js/@moq/watch.md +++ b/doc/lib/js/@moq/watch.md @@ -73,7 +73,7 @@ a real bundler (the examples below). - `latency`: Latency target. `"real-time"` (default) derives it from RTT, or a number sets a fixed jitter buffer in ms. Collapses `latency-min` and `latency-max` to one value (minimize latency). - `latency-min`: Latency floor (the jitter/startup buffer). Same units as `latency`; leaves the ceiling untouched. - `latency-max`: Latency ceiling. `"real-time"` (default) minimizes latency; a number caps at that many ms. A ceiling above the floor enables [buffered playback](#buffered-playback): build up a buffer from future-dated frames instead of skipping ahead. -- `catalog-format`: Catalog format. One of `"hang"`, `"msf"` (see [MSF](/concept/standard/msf)), or `"manual"` (supply the catalog yourself). When omitted, the format is auto-detected from the broadcast `name` extension (`.hang` or `.msf`), falling back to `"hang"`. +- `catalog-format`: Catalog format. One of `"hang"`, `"hangz"` (the [DEFLATE-compressed](/concept/layer/hang#compression) `catalog.json.z` track), `"msf"` (see [MSF](/concept/standard/msf)), or `"manual"` (supply the catalog yourself). When omitted, the format is auto-detected from the broadcast `name` extension (`.hang` or `.msf`), falling back to `"hang"`. `"hangz"` is opt-in only and never auto-detected (it shares the `.hang` suffix). ## Catalog Formats diff --git a/js/hang/src/catalog/consumer.ts b/js/hang/src/catalog/consumer.ts index 7928ad811..91e9ec2d8 100644 --- a/js/hang/src/catalog/consumer.ts +++ b/js/hang/src/catalog/consumer.ts @@ -4,18 +4,33 @@ import type * as z from "zod/mini"; import { type Root, RootSchema } from "./root.ts"; +/** Options for a catalog {@link Consumer}. */ +export interface ConsumerConfig { + /** zod schema validating each catalog. Defaults to {@link RootSchema}. */ + schema?: z.ZodMiniType; + + /** + * Whether the track's frames are DEFLATE-compressed (the `catalog.json.z` track). Must match the + * publisher. Defaults to `false`. + */ + compression?: boolean; +} + /** * Consumes a {@link Root} catalog from a track, reconstructing it from snapshots and deltas. * * A thin wrapper around the `@moq/json` consumer, pre-wired with {@link RootSchema}. Call `next()` * to get each catalog as it changes, or iterate it. Pass an extended schema (built via * `z.extend(RootSchema, ...)`) to validate and type application sections; otherwise unknown - * sections pass through untouched. + * sections pass through untouched. Set `compression` to read the `catalog.json.z` track. */ export class Consumer extends Json.Consumer { - /** Wrap `track`, validating each catalog against `schema` (defaults to {@link RootSchema}). */ - constructor(track: Moq.Track, schema?: z.ZodMiniType) { - super(track, { schema: (schema ?? RootSchema) as z.ZodMiniType }); + /** Wrap `track`, validating each catalog against `config.schema` (defaults to {@link RootSchema}). */ + constructor(track: Moq.Track, config: ConsumerConfig = {}) { + super(track, { + schema: (config.schema ?? RootSchema) as z.ZodMiniType, + compression: config.compression, + }); } } diff --git a/js/hang/src/catalog/format.ts b/js/hang/src/catalog/format.ts index 4bf1562ec..25106e295 100644 --- a/js/hang/src/catalog/format.ts +++ b/js/hang/src/catalog/format.ts @@ -5,6 +5,12 @@ // catalog track without explicit configuration; publishers should include the // suffix in the name they publish so consumers can detect it. +/** Track name for the uncompressed hang catalog (the `.json` track). */ +export const TRACK = "catalog.json"; + +/** Track name for the DEFLATE-compressed hang catalog: the `.z` sibling of {@link TRACK}. */ +export const TRACK_COMPRESSED = "catalog.json.z"; + /** Recognized catalog format suffixes used in broadcast names. */ export const FORMATS = ["hang", "msf"] as const; /** A catalog format advertised by a broadcast name suffix. */ diff --git a/js/json/src/producer.test.ts b/js/json/src/producer.test.ts index b6e346d7a..f7219f5b7 100644 --- a/js/json/src/producer.test.ts +++ b/js/json/src/producer.test.ts @@ -59,6 +59,30 @@ test("mutate without a value or initial throws", () => { expect(() => source.mutate(() => {})).toThrow(); }); +test("serve() can override compression per subscriber", async () => { + // One fan-out producer serves the same value both plaintext and compressed. + const source = new Producer>({ initial: {} }); + source.mutate((v) => { + v.video = { renditions: { hd: { codec: "avc1.640028" } } }; + }); + + const effect = new Effect(); + + const plainTrack = new Track("catalog.json"); + source.serve(plainTrack, effect); + const plain = await new Consumer>(plainTrack).next(); + + const zTrack = new Track("catalog.json.z"); + source.serve(zTrack, effect, { compression: true }); + const compressed = await new Consumer>(zTrack, { compression: true }).next(); + + // Both tracks reconstruct the identical value despite different wire encodings. + expect(compressed).toEqual(plain); + expect(compressed?.video).toEqual({ renditions: { hd: { codec: "avc1.640028" } } }); + + effect.close(); +}); + test("serve() throws on a track-bound producer", () => { const producer = new Producer>(new Track("meta.json")); const effect = new Effect(); diff --git a/js/json/src/producer.ts b/js/json/src/producer.ts index d45625348..21395c7df 100644 --- a/js/json/src/producer.ts +++ b/js/json/src/producer.ts @@ -171,13 +171,19 @@ export class Producer { * * Only available on a track-less (fan-out) producer. The subscriber is removed and finished when * `effect` is cleaned up. + * + * Pass `opts.compression` to override the producer's configured compression for this subscriber + * only, so one fan-out producer can serve the same value both plaintext and `deflate-raw` (e.g. + * the catalog served on `catalog.json` and `catalog.json.z`). */ - serve(track: Moq.Track, effect: Effect): void { + serve(track: Moq.Track, effect: Effect, opts?: { compression?: boolean }): void { if (!this.#outputs) { throw new Error("serve() is only available on a track-less Producer"); } - const output = new Producer(track, this.#config); + const config = + opts?.compression === undefined ? this.#config : { ...this.#config, compression: opts.compression }; + const output = new Producer(track, config); if (this.#value !== undefined) output.update(this.#value); this.#outputs.add(output); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index edec4ac4b..c2177d8b5 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -16,7 +16,9 @@ export type BroadcastProps = { export type ServeTrack = (track: Moq.Track, effect: Effect) => void; export class Broadcast { - static readonly CATALOG_TRACK = "catalog.json"; + static readonly CATALOG_TRACK = Catalog.TRACK; + /** The DEFLATE-compressed catalog track, served alongside {@link CATALOG_TRACK} with identical content. */ + static readonly CATALOG_TRACK_COMPRESSED = Catalog.TRACK_COMPRESSED; connection: Signal; enabled: Signal; @@ -39,6 +41,7 @@ export class Broadcast { // these would never run. `publishTrack` rejects them to fail fast. static readonly #RESERVED_TRACKS: ReadonlySet = new Set([ Broadcast.CATALOG_TRACK, + Broadcast.CATALOG_TRACK_COMPRESSED, Audio.Encoder.TRACK, Video.Root.TRACK_HD, Video.Root.TRACK_SD, @@ -107,6 +110,10 @@ export class Broadcast { case Broadcast.CATALOG_TRACK: this.catalog.serve(request.track, effect); break; + case Broadcast.CATALOG_TRACK_COMPRESSED: + // Same catalog, DEFLATE-compressed; consumers opt in by subscribing to this track. + this.catalog.serve(request.track, effect, { compression: true }); + break; case Audio.Encoder.TRACK: this.audio.serve(request.track, effect); break; diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 21dd8681b..845dfd5d2 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -9,9 +9,11 @@ import { toHang } from "./msf"; /** Consumes a custom track once subscribed, scoped to the subscription's lifetime. */ export type ConsumeTrack = (track: Moq.Track, effect: Effect) => void; -// Watch supports the two on-the-wire catalog formats from @moq/hang plus a -// "manual" mode where the user supplies the catalog directly without fetching. -export const CATALOG_FORMATS = [...Catalog.FORMATS, "manual"] as const; +// Watch supports the on-the-wire catalog formats from @moq/hang, plus "hangz" (the +// DEFLATE-compressed `catalog.json.z` track) and a "manual" mode where the user supplies the +// catalog directly without fetching. "hangz" is opt-in only: it shares the `.hang` broadcast suffix +// and is never auto-detected, so set it explicitly via `catalogFormat`. +export const CATALOG_FORMATS = [...Catalog.FORMATS, "hangz", "manual"] as const; export type CatalogFormat = (typeof CATALOG_FORMATS)[number]; export function parseCatalogFormat(value: string | null): CatalogFormat | undefined { @@ -39,7 +41,8 @@ export interface BroadcastProps { // Which catalog format to use. When `undefined` (the default), the format is // auto-detected from the broadcast name extension (`.hang`, `.msf`), falling // back to `"hang"` if the name has no recognized extension. Set to a - // specific value to override auto-detection. + // specific value to override auto-detection. `"hangz"` (the compressed + // `catalog.json.z` track) is opt-in only and never auto-detected. catalogFormat?: CatalogFormat | Signal; // Initial catalog. Used directly when catalogFormat is "manual"; otherwise it's @@ -155,15 +158,15 @@ export class Broadcast { this.status.set("loading"); - const trackName = format === "hang" ? "catalog.json" : "catalog"; + const trackName = format === "hang" ? Catalog.TRACK : format === "hangz" ? Catalog.TRACK_COMPRESSED : "catalog"; const track = broadcast.subscribe(trackName, Catalog.PRIORITY.catalog); effect.cleanup(() => track.close()); - // The hang catalog is reconstructed from snapshots (and future deltas) via @moq/json; - // MSF stays on its own one-blob-per-group fetch. + // The hang catalog is reconstructed from snapshots (and future deltas) via @moq/json, with + // "hangz" decompressing the `.z` track; MSF stays on its own one-blob-per-group fetch. let fetchNext: () => Promise; - if (format === "hang") { - const consumer = new Catalog.Consumer(track); + if (format === "hang" || format === "hangz") { + const consumer = new Catalog.Consumer(track, { compression: format === "hangz" }); fetchNext = () => consumer.next(); } else { fetchNext = async () => { diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 9c68c5c7a..070d07783 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -33,6 +33,12 @@ impl Catalog { /// The default name for the catalog track. pub const DEFAULT_NAME: &str = "catalog.json"; + /// The track name for the DEFLATE-compressed catalog: the `.z` sibling of [`DEFAULT_NAME`](Self::DEFAULT_NAME). + /// + /// Carries the identical catalog JSON, compressed per group (see `moq-json`). A publisher serves + /// both tracks; a consumer reads whichever it prefers. + pub const COMPRESSED_NAME: &str = "catalog.json.z"; + /// Parse a catalog from a string. #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Result { @@ -75,6 +81,14 @@ impl Catalog { priority: 100, } } + + /// The track carrying the DEFLATE-compressed catalog ([`COMPRESSED_NAME`](Self::COMPRESSED_NAME)). + pub fn compressed_track() -> moq_net::Track { + moq_net::Track { + name: Catalog::COMPRESSED_NAME.to_string(), + priority: 100, + } + } } #[cfg(test)] diff --git a/rs/moq-cli/src/subscribe.rs b/rs/moq-cli/src/subscribe.rs index aeae43989..6231ba23d 100644 --- a/rs/moq-cli/src/subscribe.rs +++ b/rs/moq-cli/src/subscribe.rs @@ -18,6 +18,8 @@ pub enum SubscribeFormat { #[derive(ValueEnum, Clone, Copy)] pub enum CatalogFormatArg { Hang, + #[value(name = "hangz")] + HangZ, Msf, } @@ -25,6 +27,7 @@ impl From for CatalogFormat { fn from(format: CatalogFormatArg) -> Self { match format { CatalogFormatArg::Hang => Self::Hang, + CatalogFormatArg::HangZ => Self::HangZ, CatalogFormatArg::Msf => Self::Msf, } } @@ -51,7 +54,8 @@ pub struct SubscribeArgs { /// Catalog format to subscribe to for track discovery. /// /// When omitted, the format is auto-detected from the broadcast name suffix - /// (`.hang` -> hang, `.msf` -> msf), falling back to hang. + /// (`.hang` -> hang, `.msf` -> msf), falling back to hang. Pass `hangz` to read + /// the DEFLATE-compressed `catalog.json.z` track instead (same `.hang` broadcast). #[arg(long)] pub catalog: Option, } diff --git a/rs/moq-mux/src/catalog/format.rs b/rs/moq-mux/src/catalog/format.rs index 8aa1bb504..8cf904e44 100644 --- a/rs/moq-mux/src/catalog/format.rs +++ b/rs/moq-mux/src/catalog/format.rs @@ -12,6 +12,12 @@ pub enum CatalogFormat { /// `hang` JSON catalog (track `catalog.json`). #[default] Hang, + /// DEFLATE-compressed `hang` JSON catalog (track `catalog.json.z`). + /// + /// Same broadcast-name suffix as [`Hang`](Self::Hang) (`.hang`): the compression is a track-level + /// choice, not a different broadcast. Opt in explicitly. [`detect`](Self::detect) never returns + /// this, so name-based auto-detection stays on the uncompressed track until consumers are moved over. + HangZ, /// MSF catalog (track `catalog`). Msf, } @@ -23,9 +29,12 @@ impl CatalogFormat { pub const DEFAULT: Self = Self::Hang; /// The filename-style suffix (including leading dot) for this format. + /// + /// [`Hang`](Self::Hang) and [`HangZ`](Self::HangZ) share `.hang`: the compressed track is an + /// extra track on the same broadcast, not a different broadcast name. pub fn extension(self) -> &'static str { match self { - Self::Hang => ".hang", + Self::Hang | Self::HangZ => ".hang", Self::Msf => ".msf", } } @@ -65,4 +74,12 @@ mod test { assert_eq!(CatalogFormat::detect(""), None); assert_eq!(CatalogFormat::detect("demo/foo.v2"), None); } + + #[test] + fn hangz_shares_hang_suffix_and_is_never_detected() { + // HangZ is an explicit opt-in: it reuses the `.hang` broadcast suffix and detection always + // resolves `.hang` to the uncompressed track. + assert_eq!(CatalogFormat::HangZ.extension(), ".hang"); + assert_eq!(CatalogFormat::detect("demo/bbb.hang"), Some(CatalogFormat::Hang)); + } } diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index 38ca736ad..8b9b58928 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -24,13 +24,24 @@ impl Clone for Consumer { } impl Consumer { - /// Create a new catalog consumer from a MoQ track consumer. + /// Create a new catalog consumer from a MoQ track consumer (uncompressed `catalog.json`). pub fn new(track: moq_net::TrackConsumer) -> Self { Self { inner: moq_json::Consumer::new(track, moq_json::ConsumerConfig::default()), } } + /// Create a consumer for the DEFLATE-compressed catalog track (`catalog.json.z`). + /// + /// The track must be the compressed one; pair this with [`hang::Catalog::compressed_track`]. + pub fn compressed(track: moq_net::TrackConsumer) -> Self { + let mut config = moq_json::ConsumerConfig::default(); + config.compression = true; + Self { + inner: moq_json::Consumer::new(track, config), + } + } + /// Poll for the next catalog update. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { let result = ready!(self.inner.poll_next(waiter)); diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index ba47ef859..155efd59b 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -15,13 +15,17 @@ use super::hang::{Catalog, CatalogExt, Consumer, Extra}; /// /// The JSON catalog is updated when tracks are added/removed but is *not* automatically published. /// You'll have to call [`lock`](Self::lock) to update and publish the catalog. -/// Both the hang (`catalog.json`) and MSF (`catalog`) tracks are published on drop of the guard. +/// Three tracks are published together on drop of the guard: the hang (`catalog.json`), its +/// DEFLATE-compressed `.z` sibling (`catalog.json.z`), and MSF (`catalog`). /// -/// The hang track is published through [`moq_json`], which currently emits one snapshot per +/// The hang tracks are published through [`moq_json`], which currently emits one snapshot per /// group (deltas disabled). This routes catalog publishing through the JSON merge-patch helper -/// so deltas can be enabled later without changing the wire format used today. +/// so deltas can be enabled later without changing the wire format used today. The `.z` track +/// carries the identical catalog, compressed; consumers opt into it via +/// [`CatalogFormat::HangZ`](super::CatalogFormat::HangZ). pub struct Producer { hang: moq_json::Producer>, + hangz: moq_json::Producer>, msf_track: moq_net::TrackProducer, current: Arc>>, @@ -32,6 +36,7 @@ impl Clone for Producer { fn clone(&self) -> Self { Self { hang: self.hang.clone(), + hangz: self.hangz.clone(), msf_track: self.msf_track.clone(), current: self.current.clone(), } @@ -72,15 +77,22 @@ impl Producer { catalog: Catalog, ) -> Result { let hang_track = broadcast.create_track(hang::Catalog::default_track())?; + let hangz_track = broadcast.create_track(hang::Catalog::compressed_track())?; let msf_track = broadcast.create_track(moq_net::Track::new(moq_msf::DEFAULT_NAME))?; // Disable deltas for now to stay byte-compatible with consumers that only read snapshots. let mut json_config = moq_json::ProducerConfig::default(); json_config.delta_ratio = 0; - let hang = moq_json::Producer::new(hang_track, json_config); + let hang = moq_json::Producer::new(hang_track, json_config.clone()); + + // The `.z` track carries the same catalog, DEFLATE-compressed. Deltas stay off for parity + // with the plaintext track; only the per-group compression differs. + json_config.compression = true; + let hangz = moq_json::Producer::new(hangz_track, json_config); Ok(Self { hang, + hangz, msf_track, current: Arc::new(Mutex::new(catalog)), }) @@ -91,6 +103,7 @@ impl Producer { Guard { catalog: self.current.lock().unwrap(), hang: &mut self.hang, + hangz: &mut self.hangz, msf_track: &mut self.msf_track, updated: false, } @@ -106,9 +119,15 @@ impl Producer { Ok(Consumer::new(self.hang.consume())) } + /// Create a consumer for the DEFLATE-compressed (`catalog.json.z`) catalog track. + pub fn consume_compressed(&self) -> Result, moq_net::Error> { + Ok(Consumer::compressed(self.hangz.consume())) + } + /// Finish publishing to this catalog. pub fn finish(&mut self) -> crate::Result<()> { self.hang.finish()?; + self.hangz.finish()?; self.msf_track.finish()?; Ok(()) } @@ -119,10 +138,11 @@ impl Producer { /// Obtained via [`Producer::lock`]. Derefs to the [`Catalog`](super::hang::Catalog), so `video`/`audio` /// and (through the catalog's own deref) the extension sections are editable directly. /// -/// On drop, both the hang and MSF catalog tracks are updated if the catalog was mutated. +/// On drop, the hang, compressed-hang, and MSF catalog tracks are updated if the catalog was mutated. pub struct Guard<'a, E: CatalogExt = Extra> { catalog: MutexGuard<'a, Catalog>, hang: &'a mut moq_json::Producer>, + hangz: &'a mut moq_json::Producer>, msf_track: &'a mut moq_net::TrackProducer, updated: bool, } @@ -170,9 +190,11 @@ impl Drop for Guard<'_, E> { return; } - // Publish the hang catalog (one snapshot per group while deltas are disabled). + // Publish the hang catalog (one snapshot per group while deltas are disabled), plus its + // DEFLATE-compressed `.z` sibling carrying the identical catalog. let catalog: &Catalog = &self.catalog; let _ = self.hang.update(catalog); + let _ = self.hangz.update(catalog); // Publish the MSF catalog, derived from the base media sections. let msf = to_msf(&self.catalog.media()); @@ -278,11 +300,46 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { mod test { use std::collections::BTreeMap; + use std::task::Poll; + use bytes::Bytes; use hang::catalog::{Audio, AudioCodec, AudioConfig, Container, H264, Video, VideoConfig}; use super::*; + #[test] + fn publishes_plain_and_compressed_tracks() { + let mut broadcast = moq_net::Broadcast::new().produce(); + let mut catalog = Producer::new(&mut broadcast).unwrap(); + + let consumer = broadcast.consume(); + let mut plain = Consumer::new(consumer.subscribe_track(&hang::Catalog::default_track()).unwrap()); + let mut compressed = + Consumer::compressed(consumer.subscribe_track(&hang::Catalog::compressed_track()).unwrap()); + + { + let mut guard = catalog.lock(); + guard + .audio + .renditions + .insert("audio0".to_string(), AudioConfig::new(AudioCodec::Opus, 48_000, 2)); + } + let expected = catalog.snapshot(); + + let waiter = kio::Waiter::noop(); + let got_plain = match plain.poll_next(&waiter) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("expected plain catalog, got {other:?}"), + }; + let got_compressed = match compressed.poll_next(&waiter) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("expected compressed catalog, got {other:?}"), + }; + + assert_eq!(got_plain, expected); + assert_eq!(got_compressed, expected); + } + #[test] fn convert_simple() { let mut video_config = VideoConfig::new(H264 { diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index 66b5025b7..fd0f10c06 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -44,6 +44,10 @@ impl CatalogSource { let track = broadcast.subscribe_track(&hang::Catalog::default_track())?; CatalogSource::Hang(crate::catalog::hang::Consumer::new(track)) } + CatalogFormat::HangZ => { + let track = broadcast.subscribe_track(&hang::Catalog::compressed_track())?; + CatalogSource::Hang(crate::catalog::hang::Consumer::compressed(track)) + } CatalogFormat::Msf => { let track = broadcast.subscribe_track(&moq_net::Track::new(moq_msf::DEFAULT_NAME))?; CatalogSource::Msf(crate::catalog::msf::Consumer::new(track)) From 7090bb368a7c084d2467bcde4b8ebb318e0d7833 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 24 Jun 2026 12:29:36 -0700 Subject: [PATCH 03/34] feat(moq-native): unified client TLS verification + quiche backend support (#1902) Co-authored-by: Claude Opus 4.8 --- Cargo.lock | 34 +++---- rs/moq-native/Cargo.toml | 2 +- rs/moq-native/src/noq.rs | 50 +++++++---- rs/moq-native/src/quiche.rs | 93 ++++++++++++++++--- rs/moq-native/src/quinn.rs | 50 +++++++---- rs/moq-native/src/tls.rs | 172 ++++++++++++++++++++++++++++-------- 6 files changed, 295 insertions(+), 106 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc5392b60..4a1facce2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,7 +140,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -151,7 +151,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1783,7 +1783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2283,7 +2283,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3287,7 +3287,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4443,7 +4443,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5982,7 +5982,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5995,7 +5995,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6054,7 +6054,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6075,7 +6075,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6290,7 +6290,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6701,7 +6701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6925,7 +6925,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6934,7 +6934,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8101,9 +8101,9 @@ dependencies = [ [[package]] name = "web-transport-quiche" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f79670e02d23e233f96f53a05d5cbaeb0dfcc37ee4175c93f10a8a5389ee4" +checksum = "1c946eccf30928aa98aa0454d2929f48ad952e5335e2ecbf4d0eb6263f1f98ce" dependencies = [ "boring", "bytes", @@ -8212,7 +8212,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index cbe83926f..eb6752b62 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -19,7 +19,7 @@ doctest = false default = ["quinn", "aws-lc-rs", "websocket", "tcp", "uds"] quinn = ["dep:quinn", "dep:web-transport-quinn", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch"] noq = ["dep:web-transport-noq", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch"] -quiche = ["dep:web-transport-quiche", "dep:rcgen"] +quiche = ["dep:web-transport-quiche", "dep:rcgen", "dep:reqwest"] # Filesystem watcher for hot-reloading on-disk TLS certs/keys; the QUIC backends imply it. watch = ["dep:notify"] aws-lc-rs = ["rustls/aws-lc-rs", "rcgen?/aws_lc_rs", "quinn?/rustls-aws-lc-rs"] diff --git a/rs/moq-native/src/noq.rs b/rs/moq-native/src/noq.rs index 724f830be..a43320324 100644 --- a/rs/moq-native/src/noq.rs +++ b/rs/moq-native/src/noq.rs @@ -119,6 +119,8 @@ type Result = std::result::Result; pub(crate) struct NoqClient { pub quic: noq::Endpoint, pub transport: Arc, + /// Whether an `http://` URL may bootstrap a pin (see [crate::tls::Client::allows_http_bootstrap]). + pub http_bootstrap: bool, pub versions: moq_net::Versions, } @@ -149,6 +151,7 @@ impl NoqClient { Ok(Self { quic, transport, + http_bootstrap: config.tls.allows_http_bootstrap(), versions: config.versions(), }) } @@ -172,25 +175,34 @@ impl NoqClient { let ip = crate::util::pick_addr(addrs, local).ok_or(Error::NoDnsEntries)?; if url.scheme() == "http" { - // Perform a HTTP request to fetch the certificate fingerprint. - let mut fingerprint = url.clone(); - fingerprint.set_path("/certificate.sha256"); - fingerprint.set_query(None); - fingerprint.set_fragment(None); - - tracing::warn!(url = %fingerprint, "performing insecure HTTP request for certificate"); - - let resp = reqwest::get(fingerprint.as_str()) - .await - .map_err(Error::FetchFingerprint)? - .error_for_status() - .map_err(Error::FingerprintStatus)?; - - let fingerprint = resp.text().await.map_err(Error::ReadFingerprint)?; - let fingerprint = hex::decode(fingerprint.trim())?; - - let verifier = FingerprintVerifier::new(config.crypto_provider().clone(), vec![fingerprint]); - config.dangerous().set_certificate_verifier(Arc::new(verifier)); + // Insecure per-connection bootstrap: only honored when no stronger + // verification is configured, so an attacker controlling the plaintext + // fetch can't weaken an explicit pin or re-enable disabled verification. + if self.http_bootstrap { + // Perform a HTTP request to fetch the certificate fingerprint. + let mut fingerprint = url.clone(); + fingerprint.set_path("/certificate.sha256"); + fingerprint.set_query(None); + fingerprint.set_fragment(None); + + tracing::warn!(url = %fingerprint, "performing insecure HTTP request for certificate"); + + let resp = reqwest::get(fingerprint.as_str()) + .await + .map_err(Error::FetchFingerprint)? + .error_for_status() + .map_err(Error::FingerprintStatus)?; + + let fingerprint = resp.text().await.map_err(Error::ReadFingerprint)?; + let fingerprint = hex::decode(fingerprint.trim())?; + + let verifier = FingerprintVerifier::new(config.crypto_provider().clone(), vec![fingerprint]); + config.dangerous().set_certificate_verifier(Arc::new(verifier)); + } else { + tracing::warn!( + "ignoring insecure http:// fingerprint bootstrap; using the configured TLS verification" + ); + } url.set_scheme("https").expect("failed to set scheme"); } diff --git a/rs/moq-native/src/quiche.rs b/rs/moq-native/src/quiche.rs index df4e2474d..f5f1e3b2f 100644 --- a/rs/moq-native/src/quiche.rs +++ b/rs/moq-native/src/quiche.rs @@ -21,9 +21,27 @@ pub enum Error { #[error("invalid DNS name")] InvalidDnsName, + /// No longer returned: the `http://` scheme now fetches and pins the + /// certificate fingerprint instead of failing. + #[deprecated(note = "fingerprint verification over http:// is now supported; this is never returned")] #[error("fingerprint verification (http:// scheme) is not supported with the quiche backend")] FingerprintUnsupported, + #[error("failed to fetch certificate fingerprint")] + FetchFingerprint(#[source] reqwest::Error), + + #[error("certificate fingerprint request failed")] + FingerprintStatus(#[source] reqwest::Error), + + #[error("failed to read certificate fingerprint")] + ReadFingerprint(#[source] reqwest::Error), + + #[error("invalid certificate fingerprint")] + InvalidFingerprint(#[source] hex::FromHexError), + + #[error("certificate fingerprint must be 32 bytes (SHA-256), got {0}")] + FingerprintLength(usize), + #[error("url scheme must be 'https', 'moqt', or 'moql'")] InvalidScheme, @@ -86,32 +104,52 @@ type Result = std::result::Result; #[derive(Clone)] pub(crate) struct QuicheClient { pub bind: net::SocketAddr, - pub disable_verify: bool, + /// Resolved server-verification policy, shared with the other backends. + pub verification: crate::tls::Verification, + /// Whether an `http://` URL may bootstrap a pin (see [crate::tls::Client::allows_http_bootstrap]). + pub http_bootstrap: bool, pub max_streams: u64, pub versions: moq_net::Versions, } impl QuicheClient { pub fn new(config: &ClientConfig) -> Result { - if !config.tls.root.is_empty() { - tracing::warn!("--tls-root is not supported with the quiche backend; system roots will be used"); - } - Ok(Self { bind: config.bind, - disable_verify: config.tls.disable_verify.unwrap_or_default(), + verification: config.tls.verification()?, + http_bootstrap: config.tls.allows_http_bootstrap(), max_streams: config.max_streams.unwrap_or(crate::DEFAULT_MAX_STREAMS), versions: config.versions(), }) } pub async fn connect(&self, url: Url) -> Result { + use crate::tls::Verification; + let host = url.host().ok_or(Error::InvalidDnsName)?.to_string(); let port = url.port().unwrap_or(443); - if url.scheme() == "http" { - return Err(Error::FingerprintUnsupported); - } + // `http://` fetches the relay's self-signed certificate fingerprint over + // an insecure request and pins it for this connection. It is only honored + // when no stronger verification is configured: an attacker who controls + // the plaintext fetch must not be able to weaken an explicit pin or + // re-enable verification we were told to skip. + let (url, verification) = if url.scheme() == "http" { + let mut https = url.clone(); + https.set_scheme("https").expect("https is a valid scheme"); + + if self.http_bootstrap { + let pin = fetch_fingerprint(&url).await?; + (https, Verification::Fingerprints(vec![pin])) + } else { + tracing::warn!( + "ignoring insecure http:// fingerprint bootstrap; using the configured TLS verification" + ); + (https, self.verification.clone()) + } + } else { + (url, self.verification.clone()) + }; let alpns: Vec> = match url.scheme() { "https" => vec![web_transport_quiche::ALPN.as_bytes().to_vec()], @@ -125,15 +163,26 @@ impl QuicheClient { }; let mut settings = web_transport_quiche::Settings::default(); - settings.verify_peer = !self.disable_verify; + settings.verify_peer = !matches!(verification, Verification::Disabled); settings.alpn = alpns; settings.initial_max_streams_bidi = self.max_streams; settings.initial_max_streams_uni = self.max_streams; - let builder = web_transport_quiche::ez::ClientBuilder::default() + let mut builder = web_transport_quiche::ez::ClientBuilder::default() .with_settings(settings) .with_bind(self.bind)?; + match verification { + // No hook: tokio-quiche's default config with verify_peer = false. + Verification::Disabled => {} + Verification::Fingerprints(hashes) => { + builder = builder.with_server_certificate_hashes(hashes); + } + Verification::Roots(roots) => { + builder = builder.with_root_certificates(roots); + } + } + tracing::debug!(%url, "connecting via quiche"); let mut request = web_transport_quiche::proto::ConnectRequest::new(url.clone()); @@ -177,6 +226,28 @@ impl QuicheClient { } } +/// Fetch a relay's certificate SHA-256 over an insecure `http://` request. +/// +/// This is the native equivalent of how a browser bootstraps trust for a +/// self-signed relay: GET `/certificate.sha256` and pin the returned hash. +async fn fetch_fingerprint(url: &Url) -> Result<[u8; 32]> { + let mut fp = url.clone(); + fp.set_path("/certificate.sha256"); + fp.set_query(None); + fp.set_fragment(None); + + tracing::warn!(url = %fp, "performing insecure HTTP request for certificate fingerprint"); + + let resp = reqwest::get(fp.as_str()) + .await + .map_err(Error::FetchFingerprint)? + .error_for_status() + .map_err(Error::FingerprintStatus)?; + let text = resp.text().await.map_err(Error::ReadFingerprint)?; + let bytes = hex::decode(text.trim()).map_err(Error::InvalidFingerprint)?; + bytes.try_into().map_err(|v: Vec| Error::FingerprintLength(v.len())) +} + impl Error { pub(crate) fn connect_error(&self) -> Option { match self { diff --git a/rs/moq-native/src/quinn.rs b/rs/moq-native/src/quinn.rs index 8bf032823..ac747634f 100644 --- a/rs/moq-native/src/quinn.rs +++ b/rs/moq-native/src/quinn.rs @@ -117,6 +117,8 @@ type Result = std::result::Result; pub(crate) struct QuinnClient { pub quic: quinn::Endpoint, pub transport: Arc, + /// Whether an `http://` URL may bootstrap a pin (see [crate::tls::Client::allows_http_bootstrap]). + pub http_bootstrap: bool, pub versions: moq_net::Versions, } @@ -147,6 +149,7 @@ impl QuinnClient { Ok(Self { quic, transport, + http_bootstrap: config.tls.allows_http_bootstrap(), versions: config.versions(), }) } @@ -170,25 +173,34 @@ impl QuinnClient { let ip = crate::util::pick_addr(addrs, local).ok_or(Error::NoDnsEntries)?; if url.scheme() == "http" { - // Perform a HTTP request to fetch the certificate fingerprint. - let mut fingerprint = url.clone(); - fingerprint.set_path("/certificate.sha256"); - fingerprint.set_query(None); - fingerprint.set_fragment(None); - - tracing::warn!(url = %fingerprint, "performing insecure HTTP request for certificate"); - - let resp = reqwest::get(fingerprint.as_str()) - .await - .map_err(Error::FetchFingerprint)? - .error_for_status() - .map_err(Error::FingerprintStatus)?; - - let fingerprint = resp.text().await.map_err(Error::ReadFingerprint)?; - let fingerprint = hex::decode(fingerprint.trim())?; - - let verifier = FingerprintVerifier::new(config.crypto_provider().clone(), vec![fingerprint]); - config.dangerous().set_certificate_verifier(Arc::new(verifier)); + // Insecure per-connection bootstrap: only honored when no stronger + // verification is configured, so an attacker controlling the plaintext + // fetch can't weaken an explicit pin or re-enable disabled verification. + if self.http_bootstrap { + // Perform a HTTP request to fetch the certificate fingerprint. + let mut fingerprint = url.clone(); + fingerprint.set_path("/certificate.sha256"); + fingerprint.set_query(None); + fingerprint.set_fragment(None); + + tracing::warn!(url = %fingerprint, "performing insecure HTTP request for certificate"); + + let resp = reqwest::get(fingerprint.as_str()) + .await + .map_err(Error::FetchFingerprint)? + .error_for_status() + .map_err(Error::FingerprintStatus)?; + + let fingerprint = resp.text().await.map_err(Error::ReadFingerprint)?; + let fingerprint = hex::decode(fingerprint.trim())?; + + let verifier = FingerprintVerifier::new(config.crypto_provider().clone(), vec![fingerprint]); + config.dangerous().set_certificate_verifier(Arc::new(verifier)); + } else { + tracing::warn!( + "ignoring insecure http:// fingerprint bootstrap; using the configured TLS verification" + ); + } url.set_scheme("https").expect("failed to set scheme"); } diff --git a/rs/moq-native/src/tls.rs b/rs/moq-native/src/tls.rs index a51eb8f84..9855b3e02 100644 --- a/rs/moq-native/src/tls.rs +++ b/rs/moq-native/src/tls.rs @@ -46,6 +46,11 @@ pub enum Error { #[error("invalid TLS fingerprint length: expected 32 bytes (SHA-256), got {0}")] FingerprintLength(usize), + #[error( + "--tls-fingerprint cannot be combined with --tls-root or --tls-system-roots: fingerprint pinning bypasses CA verification" + )] + FingerprintWithRoots, + #[error("failed to add root certificate")] AddRoot(#[source] rustls::Error), @@ -180,45 +185,114 @@ pub struct Client { pub disable_verify: Option, } +/// The resolved server-certificate verification policy. +/// +/// Computed once by [Client::verification] and shared by every backend (the +/// rustls-based quinn/noq via [Client::build], and quiche directly) so they +/// agree on precedence, the system-roots default, and which flag combinations +/// are valid. +#[derive(Clone)] +pub(crate) enum Verification { + /// No verification at all. Insecure; only via `--tls-disable-verify`. + Disabled, + + /// Pin the leaf certificate by SHA-256. The CA chain is not consulted, so + /// this is mutually exclusive with any roots. + Fingerprints(Vec<[u8; 32]>), + + /// Standard verification against these roots (system and/or custom, already + /// resolved). The two sets are additive. + Roots(Vec>), +} + impl Client { - /// Build a [`rustls::ClientConfig`] from this configuration. + /// Resolve the verification policy from the configured flags. /// - /// Trusts the configured roots plus the platform's native roots (the latter - /// gated by `system_roots`), optionally attaches a client identity for mTLS, - /// and swaps in fingerprint pinning or disabled verification when requested. - pub fn build(&self) -> Result { - let provider = crypto::provider(); + /// Precedence and rules (shared by all backends): + /// - `--tls-disable-verify` wins and disables verification. + /// - `--tls-fingerprint` pins the leaf and bypasses the CA chain; combining + /// it with `--tls-root` or `--tls-system-roots` is rejected rather than + /// silently ignoring one of them. + /// - Otherwise, verify against the system roots (default) plus any custom + /// roots. The system roots are dropped once a custom root is given unless + /// `--tls-system-roots` re-enables them. + pub(crate) fn verification(&self) -> Result { + if self.disable_verify.unwrap_or_default() { + return Ok(Verification::Disabled); + } + + let fingerprints = self.fingerprints()?; + if !fingerprints.is_empty() { + if !self.root.is_empty() || self.system_roots == Some(true) { + return Err(Error::FingerprintWithRoots); + } + return Ok(Verification::Fingerprints(fingerprints)); + } // Default to system roots only when no custom root is given, so passing a // root replaces them unless the system roots are explicitly re-enabled. let system_roots = self.system_roots.unwrap_or(self.root.is_empty()); - // fingerprint pinning and disable_verify swap in their own verifier below, - // so an empty root store is fine in those cases. Otherwise WebPKI needs at - // least one trusted root to ever succeed, so fail fast instead of producing - // confusing handshake errors later. - let custom_verifier = self.disable_verify.unwrap_or_default() || !self.fingerprint.is_empty(); - if !system_roots && self.root.is_empty() && !custom_verifier { - return Err(Error::NoRoots); - } - - let mut roots = rustls::RootCertStore::empty(); + let mut roots = Vec::new(); if system_roots { let native = rustls_native_certs::load_native_certs(); for err in native.errors { tracing::warn!(%err, "failed to load root cert"); } - for cert in native.certs { - roots.add(cert).map_err(Error::AddRoot)?; - } + roots.extend(native.certs); } for root in &self.root { let certs = read_certs(root)?; if certs.is_empty() { return Err(Error::EmptyRoots(root.clone())); } + roots.extend(certs); + } + + // WebPKI needs at least one trusted root to ever succeed, so fail fast + // instead of producing confusing handshake errors later. + if roots.is_empty() { + return Err(Error::NoRoots); + } + + Ok(Verification::Roots(roots)) + } + + /// Whether an insecure `http://` certificate-fingerprint bootstrap may be + /// honored for a connection. + /// + /// Only when no stronger verification is configured: an explicit + /// `--tls-fingerprint` must never be weakened by an attacker-controlled + /// plaintext fetch, and there is nothing to bootstrap when verification is + /// disabled. With CA roots (the default), `http://` is the deliberate + /// per-connection way to pin a self-signed relay, so it is allowed. + pub(crate) fn allows_http_bootstrap(&self) -> bool { + self.fingerprint.is_empty() && !self.disable_verify.unwrap_or_default() + } + + /// Parse the configured fingerprints into fixed-size SHA-256 digests. + fn fingerprints(&self) -> Result> { + self.fingerprint + .iter() + .map(|fp| { + let bytes = hex::decode(fp.trim()).map_err(Error::Fingerprint)?; + bytes.try_into().map_err(|v: Vec| Error::FingerprintLength(v.len())) + }) + .collect() + } + + /// Build a [`rustls::ClientConfig`] from this configuration. + /// + /// Resolves the verification policy, optionally attaches a client identity + /// for mTLS, and installs the matching verifier. + pub fn build(&self) -> Result { + let provider = crypto::provider(); + let verification = self.verification()?; + + let mut roots = rustls::RootCertStore::empty(); + if let Verification::Roots(certs) = &verification { for cert in certs { - roots.add(cert).map_err(Error::AddRoot)?; + roots.add(cert.clone()).map_err(Error::AddRoot)?; } } @@ -245,25 +319,21 @@ impl Client { _ => return Err(Error::IncompleteClientAuth), }; - if self.disable_verify.unwrap_or_default() { - tracing::warn!("TLS server certificate verification is disabled; A man-in-the-middle attack is possible."); - let noop = NoCertificateVerification(provider); - tls.dangerous().set_certificate_verifier(Arc::new(noop)); - } else if !self.fingerprint.is_empty() { - let fingerprints = self - .fingerprint - .iter() - .map(|fp| { - let bytes = hex::decode(fp.trim()).map_err(Error::Fingerprint)?; - match bytes.len() { - 32 => Ok(bytes), - len => Err(Error::FingerprintLength(len)), - } - }) - .collect::>>()?; - - let verifier = FingerprintVerifier::new(provider, fingerprints); - tls.dangerous().set_certificate_verifier(Arc::new(verifier)); + match verification { + Verification::Disabled => { + tracing::warn!( + "TLS server certificate verification is disabled; A man-in-the-middle attack is possible." + ); + tls.dangerous() + .set_certificate_verifier(Arc::new(NoCertificateVerification(provider))); + } + Verification::Fingerprints(fingerprints) => { + let fingerprints = fingerprints.into_iter().map(|fp| fp.to_vec()).collect(); + let verifier = FingerprintVerifier::new(provider, fingerprints); + tls.dangerous().set_certificate_verifier(Arc::new(verifier)); + } + // Roots are already in the store above; use the default WebPKI verifier. + Verification::Roots(_) => {} } Ok(tls) @@ -610,6 +680,30 @@ mod tests { }; assert!(config.build().is_ok()); } + + #[test] + fn build_rejects_fingerprint_with_roots() { + let cert = self_signed(); + let fingerprint = hex::encode(crypto::sha256(&crypto::provider(), cert.as_ref())); + + // Fingerprint pinning bypasses the CA chain, so combining it with roots + // is rejected rather than silently ignoring one of them. + let with_system = Client { + fingerprint: vec![fingerprint.clone()], + system_roots: Some(true), + ..Default::default() + }; + assert!(matches!(with_system.build(), Err(Error::FingerprintWithRoots))); + + // The conflict is detected before any root file is read, so the path + // need not exist. + let with_custom = Client { + fingerprint: vec![fingerprint], + root: vec![PathBuf::from("/does-not-exist.pem")], + ..Default::default() + }; + assert!(matches!(with_custom.build(), Err(Error::FingerprintWithRoots))); + } } // ── ServeCerts ────────────────────────────────────────────────────── From fbf706d62b56409106c1075fd9b2423ee8f5a1c6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 24 Jun 2026 12:50:11 -0700 Subject: [PATCH 04/34] feat(flate): extract group-scoped DEFLATE into moq-flate / @moq/flate (#1905) Co-authored-by: Claude Opus 4.8 --- Cargo.lock | 13 +- Cargo.toml | 5 +- bun.lock | 22 +- js/CLAUDE.md | 3 +- js/flate/README.md | 35 +++ js/flate/package.json | 28 ++ js/flate/src/index.test.ts | 148 +++++++++ .../src/compression.ts => flate/src/index.ts} | 86 +++-- js/flate/tsconfig.json | 9 + js/json/package.json | 8 +- js/json/src/compression.test.ts | 133 +------- js/json/src/consumer.ts | 2 +- js/json/src/producer.ts | 2 +- package.json | 1 + rs/CLAUDE.md | 3 +- rs/moq-flate/Cargo.toml | 21 ++ rs/moq-flate/src/lib.rs | 294 ++++++++++++++++++ rs/moq-json/Cargo.toml | 4 +- rs/moq-json/examples/telemetry.rs | 210 +++++++++++++ rs/moq-json/src/compression.rs | 224 ------------- rs/moq-json/src/lib.rs | 130 +++++++- 21 files changed, 960 insertions(+), 421 deletions(-) create mode 100644 js/flate/README.md create mode 100644 js/flate/package.json create mode 100644 js/flate/src/index.test.ts rename js/{json/src/compression.ts => flate/src/index.ts} (50%) create mode 100644 js/flate/tsconfig.json create mode 100644 rs/moq-flate/Cargo.toml create mode 100644 rs/moq-flate/src/lib.rs create mode 100644 rs/moq-json/examples/telemetry.rs delete mode 100644 rs/moq-json/src/compression.rs diff --git a/Cargo.lock b/Cargo.lock index 4a1facce2..3b73e0f23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3826,6 +3826,15 @@ dependencies = [ "url", ] +[[package]] +name = "moq-flate" +version = "0.1.0" +dependencies = [ + "bytes", + "flate2", + "thiserror 2.0.18", +] + [[package]] name = "moq-gst" version = "0.2.8" @@ -3846,12 +3855,12 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.0.4" +version = "0.1.0" dependencies = [ "bytes", - "flate2", "json-patch", "kio", + "moq-flate", "moq-net", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index be0c4d56f..856b72993 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "rs/moq-boy", "rs/moq-cli", "rs/moq-ffi", + "rs/moq-flate", "rs/moq-gst", "rs/moq-json", "rs/moq-loc", @@ -30,6 +31,7 @@ default-members = [ "rs/moq-cli", # "rs/moq-ffi", # requires Python/maturin # "rs/moq-gst", # requires GStreamer + "rs/moq-flate", "rs/moq-json", "rs/moq-loc", "rs/moq-msf", @@ -51,7 +53,8 @@ flate2 = "1" hang = { version = "0.19", path = "rs/hang" } kio = { version = "0.4", path = "rs/kio" } moq-audio = { version = "0.0.5", path = "rs/moq-audio" } -moq-json = { version = "0.0.4", path = "rs/moq-json" } +moq-flate = { version = "0.1.0", path = "rs/moq-flate" } +moq-json = { version = "0.1.0", path = "rs/moq-json" } moq-loc = { version = "0.1", path = "rs/moq-loc" } moq-msf = { version = "0.2", path = "rs/moq-msf" } moq-mux = { version = "0.6", path = "rs/moq-mux" } diff --git a/bun.lock b/bun.lock index 13f5fb717..224193a27 100644 --- a/bun.lock +++ b/bun.lock @@ -78,6 +78,20 @@ "typescript": "^6.0.3", }, }, + "js/flate": { + "name": "@moq/flate", + "version": "0.1.0", + "dependencies": { + "pako": "^2.2.0", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "@types/pako": "^2.0.4", + "fflate": "^0.8.2", + "rimraf": "^6.1.3", + "typescript": "^6.0.3", + }, + }, "js/hang": { "name": "@moq/hang", "version": "0.2.11", @@ -102,16 +116,14 @@ }, "js/json": { "name": "@moq/json", - "version": "0.0.3", + "version": "0.1.0", "dependencies": { + "@moq/flate": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "pako": "^2.2.0", }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/pako": "^2.0.4", - "fflate": "^0.8.2", "rimraf": "^6.1.3", "typescript": "^6.0.3", }, @@ -552,6 +564,8 @@ "@moq/demo-boy": ["@moq/demo-boy@workspace:demo/boy"], + "@moq/flate": ["@moq/flate@workspace:js/flate"], + "@moq/hang": ["@moq/hang@workspace:js/hang"], "@moq/json": ["@moq/json@workspace:js/json"], diff --git a/js/CLAUDE.md b/js/CLAUDE.md index b47385532..0ba19177c 100644 --- a/js/CLAUDE.md +++ b/js/CLAUDE.md @@ -14,7 +14,8 @@ Bun workspaces; members listed in the repo-root `package.json` (not in `js/`). D **Container / catalog formats** - `@moq/loc` (`loc/`): Low Overhead Container frame encoding. Thin layer on `@moq/net`. -- `@moq/json` (`json/`): snapshot/delta JSON over a track via RFC 7396 merge-patch. Exposes the base `Producer`/`Consumer` that `@moq/hang`'s catalog extends. +- `@moq/json` (`json/`): snapshot/delta JSON over a track via RFC 7396 merge-patch. Exposes the base `Producer`/`Consumer` that `@moq/hang`'s catalog extends. DEFLATE via `@moq/flate`. +- `@moq/flate` (`flate/`): group-scoped DEFLATE primitive (only deps on `pako`). `Encoder`/`Decoder` turn a stream of payloads into self-delimited sync-flushed frames sharing one window; wire-interoperable with the Rust `moq-flate` crate. Used by `@moq/json`. - `@moq/msf` (`msf/`): MOQT Streaming Format catalog types (zod schemas). **Media** diff --git a/js/flate/README.md b/js/flate/README.md new file mode 100644 index 000000000..c013015d9 --- /dev/null +++ b/js/flate/README.md @@ -0,0 +1,35 @@ +

+ Media over QUIC +

+ +# @moq/flate + +[![npm version](https://img.shields.io/npm/v/@moq/flate)](https://www.npmjs.com/package/@moq/flate) +[![TypeScript](https://img.shields.io/badge/TypeScript-ready-blue.svg)](https://www.typescriptlang.org/) + +Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window. + +A sequence of frame payloads is compressed into a single raw DEFLATE ([RFC 1951](https://www.rfc-editor.org/rfc/rfc1951.html)) stream, sync-flushed at each frame boundary. Every frame is self-delimited (byte-aligned, the window retained) while later frames reuse the earlier ones as context, so a stream of similar payloads (a snapshot followed by deltas, repeated records, log lines) compresses far better than each payload alone. + +This is plain raw DEFLATE with a `Z_SYNC_FLUSH` after each frame, so it interoperates on the wire with any peer using the same primitive, including the Rust [`moq-flate`](https://crates.io/crates/moq-flate) crate. The fixed 4-byte sync-flush marker is stripped per frame ([RFC 7692](https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1)'s permessage-deflate trick). There is no length prefix: the caller frames each slice ([`@moq/net`](../net) already does). + +## Quick Start + +```bash +npm add @moq/flate +``` + +```ts +import { Encoder, Decoder } from "@moq/flate"; + +const encoder = new Encoder(); +const a = encoder.frame(new TextEncoder().encode("the quick brown fox")); +const b = encoder.frame(new TextEncoder().encode("the quick brown dog")); // smaller: reuses the window + +// Feed slices to the decoder in the same order they were produced. +const decoder = new Decoder(); +new TextDecoder().decode(decoder.frame(a)); // "the quick brown fox" +new TextDecoder().decode(decoder.frame(b)); // "the quick brown dog" +``` + +Create a fresh `Encoder`/`Decoder` pair per independent stream (in moq-net terms, per group). `new Encoder({ level })` sets the DEFLATE level (`0..=9`, default `6`); `new Decoder({ maxFrameSize })` caps how far a single frame may inflate (default 64 MiB), rejecting zip bombs. diff --git a/js/flate/package.json b/js/flate/package.json new file mode 100644 index 000000000..0bfec85c6 --- /dev/null +++ b/js/flate/package.json @@ -0,0 +1,28 @@ +{ + "name": "@moq/flate", + "type": "module", + "version": "0.1.0", + "description": "Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window.", + "license": "(MIT OR Apache-2.0)", + "repository": "github:moq-dev/moq", + "sideEffects": false, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "rimraf dist && tsc -b && bun ../common/package.ts", + "check": "tsc --noEmit", + "test": "bun test --only-failures", + "release": "bun ../common/release.ts" + }, + "dependencies": { + "pako": "^2.2.0" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "@types/pako": "^2.0.4", + "fflate": "^0.8.2", + "rimraf": "^6.1.3", + "typescript": "^6.0.3" + } +} diff --git a/js/flate/src/index.test.ts b/js/flate/src/index.test.ts new file mode 100644 index 000000000..3e1c31532 --- /dev/null +++ b/js/flate/src/index.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { Deflate, Inflate } from "fflate"; +import { Decoder, Encoder } from "./index.ts"; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +// Round-trip frames through fflate's streaming `Deflate.flush(true)` + `Inflate`, the same +// shared-window scheme our pako codec uses. Returns true only if every frame survives unchanged. +function fflateRoundTrips(frames: Uint8Array[]): boolean { + try { + let captured: Uint8Array[] = []; + const deflate = new Deflate({ level: 6 }); + deflate.ondata = (chunk) => captured.push(chunk); + const slices = frames.map((frame) => { + captured = []; + deflate.push(frame, false); + deflate.flush(true); // sync flush: byte-align and retain the window + return concatBytes(captured); + }); + + let inflated: Uint8Array[] = []; + const inflate = new Inflate(); + inflate.ondata = (chunk) => inflated.push(chunk); + return slices.every((slice, i) => { + inflated = []; + inflate.push(slice, false); + const got = concatBytes(inflated); + return got.length === frames[i].length && got.every((b, j) => b === frames[i][j]); + }); + } catch { + return false; + } +} + +test("codec round-trips a stream of frames in order", () => { + const frames = ["the quick brown fox", "the quick brown dog", "the lazy fox"]; + const encoder = new Encoder(); + const slices = frames.map((f) => encoder.frame(enc.encode(f))); + + const decoder = new Decoder(); + expect(slices.map((s) => dec.decode(decoder.frame(s)))).toEqual(frames); +}); + +test("codec round-trips an empty frame", () => { + const encoder = new Encoder(); + const decoder = new Decoder(); + expect(encoder.frame(new Uint8Array()).length).toBe(0); + expect(decoder.frame(new Uint8Array()).length).toBe(0); +}); + +test("codec rejects garbage", () => { + const decoder = new Decoder(); + expect(() => decoder.frame(new Uint8Array(64).fill(0xff))).toThrow(); +}); + +test("codec rejects frames that inflate past the default cap", () => { + // A tiny slice can inflate enormously, so the decoder bounds the output as it is produced. + const encoder = new Encoder(); + const decoder = new Decoder(); + const slice = encoder.frame(enc.encode("a".repeat(64 * 1024 * 1024 + 1))); + expect(() => decoder.frame(slice)).toThrow(/exceeded/); +}); + +test("codec honors a custom maxFrameSize", () => { + const slice = new Encoder().frame(new Uint8Array(1024)); + const decoder = new Decoder({ maxFrameSize: 512 }); + expect(() => decoder.frame(slice)).toThrow(/exceeded 512/); +}); + +test("a frame larger than pako's chunk size round-trips", () => { + // High-entropy data barely compresses, so the slice spans multiple pako chunks (>16 KB), which + // exercises the encoder's multi-chunk assembly and the decoder's multi-chunk concat. + let state = 0x9e3779b9 >>> 0; + const payload = new Uint8Array(64 * 1024); + for (let i = 0; i < payload.length; i++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + payload[i] = state & 0xff; + } + + const slice = new Encoder().frame(payload); + expect(slice.length).toBeGreaterThan(16 * 1024); // pako's default chunkSize + expect(new Decoder().frame(slice)).toEqual(payload); +}); + +test("cross-frame context shrinks a repeated frame", () => { + // A later frame identical to an earlier one compresses far smaller once the window holds it. + const encoder = new Encoder(); + const payload = enc.encode("Media over QUIC delivers real-time latency at massive scale.".repeat(6)); + const first = encoder.frame(payload); + const second = encoder.frame(payload); + expect(second.length).toBeLessThan(first.length); +}); + +test("a custom level round-trips", () => { + const payload = enc.encode("compress me at maximum effort".repeat(8)); + const slice = new Encoder({ level: 9 }).frame(payload); + expect(new Decoder().frame(slice)).toEqual(payload); +}); + +test("pako round-trips a stream that fflate's flush corrupts", () => { + // A catalog snapshot + 3 deltas that fflate's streaming flush mis-encodes: even fflate's own + // Inflate can't round-trip its output here. This pins why we depend on pako, not the smaller + // fflate. If this ever fails (fflateRoundTrips returns true), fflate may have fixed its sync-flush + // encoder, and dropping the pako dependency could be reconsidered. + const stream = [ + { + video: { + renditions: { + v0: { codec: "avc1.64001f", bitrate: 6000000 }, + v1: { codec: "avc1.640015", bitrate: 3000000 }, + }, + }, + audio: { renditions: { a0: { codec: "opus", bitrate: 128000 } } }, + }, + { video: { renditions: { v0: { bitrate: 6200000 } } } }, + { video: { renditions: { v0: { bitrate: 5800000 } } } }, + { audio: { renditions: { a0: { bitrate: 96000 } } } }, + ]; + const frames = stream.map((value) => enc.encode(JSON.stringify(value))); + + // Our pako codec round-trips every frame of the stream exactly. + const encoder = new Encoder(); + const decoder = new Decoder(); + for (const frame of frames) { + expect(decoder.frame(encoder.frame(frame))).toEqual(frame); + } + + // Positive control: fflate's flush works on simpler frames, so the helper is sound and fflate is + // only selectively broken, not failing for some unrelated reason. + expect(fflateRoundTrips(["the quick brown fox", "the quick brown dog"].map((s) => enc.encode(s)))).toBe(true); + + // fflate's streaming flush does not round-trip the same stream our pako codec handles. + expect(fflateRoundTrips(frames)).toBe(false); +}); diff --git a/js/json/src/compression.ts b/js/flate/src/index.ts similarity index 50% rename from js/json/src/compression.ts rename to js/flate/src/index.ts index db874ff3e..e40d0d24e 100644 --- a/js/json/src/compression.ts +++ b/js/flate/src/index.ts @@ -1,18 +1,23 @@ /** - * Group-scoped DEFLATE compression for the JSON frame stream, using + * Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window, using * {@link https://github.com/nodeca/pako | pako}'s streaming deflate/inflate. * - * Within a group the frame payloads form a single raw DEFLATE + * A sequence of frame payloads is compressed into a single raw DEFLATE * ([RFC 1951](https://www.rfc-editor.org/rfc/rfc1951.html)) stream, sync-flushed at each frame - * boundary so every frame is self-delimited while later frames reuse the earlier ones as context - * (a snapshot followed by deltas compresses far better than each frame alone). This matches the - * Rust `moq-json` producer, so the two interoperate on the wire. + * boundary, so every frame is self-delimited (byte-aligned, the window retained) while later frames + * reuse the earlier ones as context. A stream of similar payloads (a snapshot followed by deltas, + * repeated records, log lines) compresses far better than each payload alone. Create a fresh + * {@link Encoder}/{@link Decoder} pair per independent stream (in moq-net terms, per group). * - * A sync flush always ends in the fixed 4-byte marker `00 00 ff ff`. {@link Encoder.frame} drops - * it and {@link Decoder.frame} re-appends it, saving 4 bytes per frame, the same trick - * [RFC 7692](https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1) (permessage-deflate) uses. - * moq-net frames each slice, so there's no length prefix; {@link Decoder.frame} instead caps the - * inflated output as it is produced. + * This is plain raw DEFLATE with a `Z_SYNC_FLUSH` after each frame, so any peer using the same + * primitive (the Rust `moq-flate` crate, zlib's sync flush) interoperates on the wire. There is no + * length prefix: the caller frames each slice (moq-net already does). + * + * A sync flush always ends in the fixed 4-byte marker `00 00 ff ff`. {@link Encoder.frame} drops it + * and {@link Decoder.frame} re-appends it, saving 4 bytes per frame, the same trick + * [RFC 7692](https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1) (permessage-deflate) uses. A + * small slice can still inflate enormously, so {@link Decoder.frame} caps the inflated output as it + * is produced. * * pako is synchronous, so the whole codec is synchronous; it is a normal dependency. * @@ -21,10 +26,11 @@ import * as pako from "pako"; -// Maximum decompressed size of a single frame. A malicious publisher could otherwise send a tiny -// slice that inflates hugely, so {@link Decoder} stops retaining output past this and rejects the -// frame. Mirrors the Rust `MAX_DECOMPRESSED_FRAME`. -const MAX_DECOMPRESSED_FRAME = 64 * 1024 * 1024; +/** The default DEFLATE level ({@link Encoder}): a good size/speed balance for small, repetitive payloads. */ +export const DEFAULT_LEVEL = 6; + +/** The default per-frame decompressed-size cap ({@link Decoder}): 64 MiB. */ +export const DEFAULT_MAX_FRAME_SIZE = 64 * 1024 * 1024; // The trailing bytes of a DEFLATE sync flush, stripped on the wire and re-appended to decode. const SYNC_FLUSH_TAIL = new Uint8Array([0x00, 0x00, 0xff, 0xff]); @@ -42,19 +48,29 @@ function concat(chunks: Uint8Array[], total: number): Uint8Array { return out; } +/** Options for an {@link Encoder}. */ +export interface EncoderOptions { + /** DEFLATE level, `0..=9` (higher is smaller and slower). Defaults to {@link DEFAULT_LEVEL}. */ + level?: number; +} + /** - * Encodes a group's frame payloads into one shared DEFLATE stream, one self-delimited slice per - * frame. Hold one per group; create a new one at each group boundary. + * Encodes a stream's frame payloads into one shared DEFLATE window, one self-delimited slice per + * frame. Hold one per stream; create a new one for each independent stream. * * @public */ export class Encoder { - #deflate = new pako.Deflate({ raw: true }); + #deflate: pako.Deflate; #chunks: Uint8Array[] = []; #total = 0; - /** Start a fresh per-group encoder with a cold window. */ - constructor() { + /** Start a fresh encoder with a cold window. */ + constructor(options: EncoderOptions = {}) { + // `raw`: no zlib header/trailer, matching the Rust side and the browser's `deflate-raw`. + // pako types `level` as a literal union; we accept a plain number and narrow here. + const level = (options.level ?? DEFAULT_LEVEL) as pako.DeflateOptions["level"]; + this.#deflate = new pako.Deflate({ raw: true, level }); this.#deflate.onData = (chunk) => { const bytes = chunk as Uint8Array; this.#chunks.push(bytes); @@ -63,9 +79,8 @@ export class Encoder { } /** - * Compress the next frame's `payload`, returning its slice of the group stream: the DEFLATE bytes - * minus the fixed sync-flush marker. Empty in yields empty out. Slices must be produced in frame - * order. + * Compress the next frame's `payload`, returning its slice of the stream: the DEFLATE bytes minus + * the fixed sync-flush marker. Empty in yields empty out. Slices must be produced in frame order. */ frame(payload: Uint8Array): Uint8Array { if (payload.length === 0) return payload; @@ -74,8 +89,8 @@ export class Encoder { this.#deflate.push(payload, pako.constants.Z_SYNC_FLUSH); // Copy into one tight owned buffer, dropping the trailing sync-flush marker. We can't return - // pako's chunk views: `writeFrame` retains the reference and pako backs each chunk with a - // ~16 KB buffer, so a view would pin far more memory than the frame. + // pako's chunk views: a caller retains the reference and pako backs each chunk with a ~16 KB + // buffer, so a view would pin far more memory than the frame. const out = new Uint8Array(this.#total - SYNC_FLUSH_TAIL.length); let offset = 0; for (const chunk of this.#chunks) { @@ -88,8 +103,17 @@ export class Encoder { } } +/** Options for a {@link Decoder}. */ +export interface DecoderOptions { + /** + * Per-frame decompressed-size cap. A frame that inflates past this is rejected (zip-bomb guard). + * Defaults to {@link DEFAULT_MAX_FRAME_SIZE}. + */ + maxFrameSize?: number; +} + /** - * Decodes a group's frame slices back into the original payloads. Hold one per group; feed slices + * Decodes a stream's frame slices back into the original payloads. Hold one per stream; feed slices * in frame order (each frame builds on the earlier ones). * * @public @@ -99,15 +123,17 @@ export class Decoder { #chunks: Uint8Array[] = []; #total = 0; #tooLarge = false; + #maxFrameSize: number; - /** Start a fresh per-group decoder with a cold window. */ - constructor() { + /** Start a fresh decoder with a cold window. */ + constructor(options: DecoderOptions = {}) { + this.#maxFrameSize = options.maxFrameSize ?? DEFAULT_MAX_FRAME_SIZE; this.#inflate.onData = (chunk) => { const bytes = chunk as Uint8Array; this.#total += bytes.length; // Bound the inflated output as it is produced; a tiny slice can expand enormously. Stop // retaining past the cap, then reject once the push returns. - if (this.#total > MAX_DECOMPRESSED_FRAME) { + if (this.#total > this.#maxFrameSize) { this.#tooLarge = true; return; } @@ -117,7 +143,7 @@ export class Decoder { /** * Decompress the next frame's `slice` back into its payload. Empty in yields empty out. Throws if - * the input is malformed or inflates past the per-frame size limit. + * the input is malformed or inflates past the per-frame size cap. */ frame(slice: Uint8Array): Uint8Array { if (slice.length === 0) return slice; @@ -131,7 +157,7 @@ export class Decoder { this.#inflate.push(slice, false); this.#inflate.push(SYNC_FLUSH_TAIL, pako.constants.Z_SYNC_FLUSH); if (this.#inflate.err) throw new Error(`decompression failed: ${this.#inflate.msg}`); - if (this.#tooLarge) throw new Error(`decompressed frame exceeded ${MAX_DECOMPRESSED_FRAME} bytes`); + if (this.#tooLarge) throw new Error(`decompressed frame exceeded ${this.#maxFrameSize} bytes`); return concat(this.#chunks, this.#total); } diff --git a/js/flate/tsconfig.json b/js/flate/tsconfig.json new file mode 100644 index 000000000..bb55d7c43 --- /dev/null +++ b/js/flate/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src", + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/js/json/package.json b/js/json/package.json index 5a104a402..a02537bef 100644 --- a/js/json/package.json +++ b/js/json/package.json @@ -1,7 +1,7 @@ { "name": "@moq/json", "type": "module", - "version": "0.0.3", + "version": "0.1.0", "description": "Snapshot/delta JSON publishing over MoQ tracks using RFC 7396 JSON Merge Patch.", "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", @@ -16,17 +16,15 @@ "release": "bun ../common/release.ts" }, "dependencies": { + "@moq/flate": "workspace:^", "@moq/net": "workspace:^", - "@moq/signals": "workspace:^", - "pako": "^2.2.0" + "@moq/signals": "workspace:^" }, "peerDependencies": { "zod": "^4.0.0" }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/pako": "^2.0.4", - "fflate": "^0.8.2", "rimraf": "^6.1.3", "typescript": "^6.0.3" } diff --git a/js/json/src/compression.test.ts b/js/json/src/compression.test.ts index f9257cb36..e7fd70939 100644 --- a/js/json/src/compression.test.ts +++ b/js/json/src/compression.test.ts @@ -1,7 +1,6 @@ import { expect, test } from "bun:test"; +import { Decoder } from "@moq/flate"; import { Track } from "@moq/net"; -import { Deflate, Inflate } from "fflate"; -import { Decoder, Encoder } from "./compression.ts"; import { Consumer } from "./consumer.ts"; import { Producer } from "./producer.ts"; @@ -10,44 +9,6 @@ type Value = Record; const enc = new TextEncoder(); const dec = new TextDecoder(); -function concatBytes(chunks: Uint8Array[]): Uint8Array { - const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.length; - } - return out; -} - -// Round-trip frames through fflate's streaming `Deflate.flush(true)` + `Inflate`, the same -// shared-window scheme our pako codec uses. Returns true only if every frame survives unchanged. -function fflateRoundTrips(frames: Uint8Array[]): boolean { - try { - let captured: Uint8Array[] = []; - const deflate = new Deflate({ level: 6 }); - deflate.ondata = (chunk) => captured.push(chunk); - const slices = frames.map((frame) => { - captured = []; - deflate.push(frame, false); - deflate.flush(true); // sync flush: byte-align and retain the window - return concatBytes(captured); - }); - - let inflated: Uint8Array[] = []; - const inflate = new Inflate(); - inflate.ondata = (chunk) => inflated.push(chunk); - return slices.every((slice, i) => { - inflated = []; - inflate.push(slice, false); - const got = concatBytes(inflated); - return got.length === frames[i].length && got.every((b, j) => b === frames[i][j]); - }); - } catch { - return false; - } -} - // Reconstruct every value a compressed consumer yields, in order. async function drainCompressed(track: Track): Promise { const out: Value[] = []; @@ -64,62 +25,6 @@ async function firstFrame(track: Track): Promise { return frame; } -test("codec round-trips a group of frames in order", async () => { - const frames = ["the quick brown fox", "the quick brown dog", "the lazy fox"]; - const encoder = new Encoder(); - const slices = frames.map((f) => encoder.frame(enc.encode(f))); - - const decoder = new Decoder(); - expect(slices.map((s) => dec.decode(decoder.frame(s)))).toEqual(frames); -}); - -test("codec round-trips an empty frame", async () => { - const encoder = new Encoder(); - const decoder = new Decoder(); - expect(encoder.frame(new Uint8Array()).length).toBe(0); - expect(decoder.frame(new Uint8Array()).length).toBe(0); -}); - -test("codec rejects garbage", async () => { - const decoder = new Decoder(); - expect(() => decoder.frame(new Uint8Array(64).fill(0xff))).toThrow(); -}); - -test("codec rejects frames that inflate past the cap", async () => { - // A tiny slice can inflate enormously, so the decoder bounds the output as it is produced. - const encoder = new Encoder(); - const decoder = new Decoder(); - const slice = encoder.frame(enc.encode("a".repeat(64 * 1024 * 1024 + 1))); - expect(() => decoder.frame(slice)).toThrow(/exceeded/); -}); - -test("a frame larger than pako's chunk size round-trips", () => { - // High-entropy data barely compresses, so the slice spans multiple pako chunks (>16 KB), which - // exercises the encoder's multi-chunk assembly and the decoder's multi-chunk concat. - let state = 0x9e3779b9 >>> 0; - const payload = new Uint8Array(64 * 1024); - for (let i = 0; i < payload.length; i++) { - state ^= state << 13; - state ^= state >>> 17; - state ^= state << 5; - state >>>= 0; - payload[i] = state & 0xff; - } - - const slice = new Encoder().frame(payload); - expect(slice.length).toBeGreaterThan(16 * 1024); // pako's default chunkSize - expect(new Decoder().frame(slice)).toEqual(payload); -}); - -test("cross-frame context shrinks a repeated frame", async () => { - // A later frame identical to an earlier one compresses far smaller once the window holds it. - const encoder = new Encoder(); - const payload = enc.encode("Media over QUIC delivers real-time latency at massive scale.".repeat(6)); - const first = encoder.frame(payload); - const second = encoder.frame(payload); - expect(second.length).toBeLessThan(first.length); -}); - test("compressed snapshot per group round-trips", async () => { const track = new Track("test"); const producer = new Producer(track, { deltaRatio: 0, compression: true }); @@ -197,42 +102,6 @@ test("compressed deltas reuse the window", async () => { expect(delta.length).toBeLessThan(rawDelta.length / 2); }); -test("pako round-trips a group that fflate's flush corrupts", async () => { - // A catalog snapshot + 3 deltas that fflate's streaming flush mis-encodes: even fflate's own - // Inflate can't round-trip its output here. This pins why @moq/json depends on pako, not the - // smaller fflate. If this ever fails (fflateRoundTrips returns true), fflate may have fixed its - // sync-flush encoder, and dropping the pako dependency could be reconsidered. - const group: Value[] = [ - { - video: { - renditions: { - v0: { codec: "avc1.64001f", bitrate: 6000000 }, - v1: { codec: "avc1.640015", bitrate: 3000000 }, - }, - }, - audio: { renditions: { a0: { codec: "opus", bitrate: 128000 } } }, - }, - { video: { renditions: { v0: { bitrate: 6200000 } } } }, - { video: { renditions: { v0: { bitrate: 5800000 } } } }, - { audio: { renditions: { a0: { bitrate: 96000 } } } }, - ]; - const frames = group.map((value) => enc.encode(JSON.stringify(value))); - - // Our pako codec round-trips every frame of the group exactly. - const encoder = new Encoder(); - const decoder = new Decoder(); - for (const frame of frames) { - expect(decoder.frame(encoder.frame(frame))).toEqual(frame); - } - - // Positive control: fflate's flush works on simpler frames, so the helper is sound and fflate is - // only selectively broken, not failing for some unrelated reason. - expect(fflateRoundTrips(["the quick brown fox", "the quick brown dog"].map((s) => enc.encode(s)))).toBe(true); - - // fflate's streaming flush does not round-trip the same group our pako codec handles. - expect(fflateRoundTrips(frames)).toBe(false); -}); - test("compression shrinks a repetitive frame", async () => { const value = { renditions: Array(3).fill("video".repeat(50)) }; diff --git a/js/json/src/consumer.ts b/js/json/src/consumer.ts index 3d2e36b3e..721edf974 100644 --- a/js/json/src/consumer.ts +++ b/js/json/src/consumer.ts @@ -1,6 +1,6 @@ +import { Decoder } from "@moq/flate"; import type * as Moq from "@moq/net"; import type * as z from "zod/mini"; -import { Decoder } from "./compression.ts"; import { merge } from "./diff.ts"; import type { Config } from "./producer.ts"; diff --git a/js/json/src/producer.ts b/js/json/src/producer.ts index 21395c7df..afea510a2 100644 --- a/js/json/src/producer.ts +++ b/js/json/src/producer.ts @@ -1,8 +1,8 @@ +import { Encoder } from "@moq/flate"; import * as Moq from "@moq/net"; import type { Effect } from "@moq/signals"; import type * as z from "zod/mini"; -import { Encoder } from "./compression.ts"; import { deepEqual, diff } from "./diff.ts"; // Maximum frames (snapshot + deltas) in a single group before a new snapshot is forced. Kept diff --git a/package.json b/package.json index 8f8dd49b2..1b6dcb8a2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "js/net", "js/clock", "js/token", + "js/flate", "js/json", "js/hang", "js/loc", diff --git a/rs/CLAUDE.md b/rs/CLAUDE.md index 17eecb66d..f8b991b79 100644 --- a/rs/CLAUDE.md +++ b/rs/CLAUDE.md @@ -17,7 +17,8 @@ Layered roughly transport -> container/format -> media -> apps/bindings. - `hang` (lib): media layer on `moq-net`. `catalog/` is the JSON manifest (`Catalog`, root.rs); `container/` is the frame format (timestamp + codec payload, `container::Frame`). - `moq-loc` (lib): LOC (Low Overhead Container) wire frame codec. Top-level `encode`/`decode` + `Frame`. QUIC varints, property KVPs. - `moq-msf` (lib): IETF MSF/CMSF catalog types (`Catalog`, `Track`, `Packaging`, `Role`). serde JSON. Alternative to hang's catalog. -- `moq-json` (lib): generic snapshot/delta value publishing over a track using RFC 7396 JSON Merge Patch. `Producer`/`Consumer`, `Guard` (RAII edit). Late joiners reconstruct from snapshot + deltas. +- `moq-json` (lib): generic snapshot/delta value publishing over a track using RFC 7396 JSON Merge Patch. `Producer`/`Consumer`, `Guard` (RAII edit). Late joiners reconstruct from snapshot + deltas. DEFLATE via `moq-flate`. +- `moq-flate` (lib): group-scoped DEFLATE primitive (no moq deps). `Encoder`/`Decoder` turn a stream of payloads into self-delimited sync-flushed frames sharing one window (RFC 7692 marker trick), so similar frames compress against the earlier ones. Used by `moq-json`; reusable by any framed stream. **Media bridge / codecs** - `moq-mux` (lib): the conversion layer. File/stream formats (`container/`: fmp4, flv, hls, mkv, ts, loc) and codec parsers (`codec/`: h264, h265, av1, vp8/9, opus, aac, ...) <-> hang broadcasts. `Container` trait + generic `Producer`/`Consumer`. Dual catalog (`catalog::hang`, `catalog::msf`). diff --git a/rs/moq-flate/Cargo.toml b/rs/moq-flate/Cargo.toml new file mode 100644 index 000000000..03926fc9d --- /dev/null +++ b/rs/moq-flate/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "moq-flate" +description = "Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window." +authors = ["Luke Curley "] +repository = "https://github.com/moq-dev/moq" +license = "MIT OR Apache-2.0" + +version = "0.1.0" +edition = "2024" +rust-version.workspace = true + +keywords = ["deflate", "compression", "streaming", "live"] +categories = ["compression"] + +[lib] +doctest = false + +[dependencies] +bytes = "1" +flate2 = { workspace = true } +thiserror = "2" diff --git a/rs/moq-flate/src/lib.rs b/rs/moq-flate/src/lib.rs new file mode 100644 index 000000000..ae6a6db96 --- /dev/null +++ b/rs/moq-flate/src/lib.rs @@ -0,0 +1,294 @@ +//! Group-scoped DEFLATE: a stream of self-delimited frames sharing one compression window. +//! +//! A sequence of frame payloads is compressed into a single raw DEFLATE ([RFC 1951]) stream, +//! sync-flushed at each frame boundary. Every frame is therefore self-delimited (byte-aligned, the +//! window retained) while later frames reuse the earlier ones as context, so a stream of similar +//! payloads (a snapshot followed by deltas, repeated records, log lines) compresses far better than +//! each payload alone. The [`Encoder`]/[`Decoder`] hold that shared window; create a fresh pair per +//! independent stream (in moq-net terms, per group). +//! +//! This is plain raw DEFLATE with a `Z_SYNC_FLUSH` after each frame, so any peer using the same +//! primitive (zlib's sync flush, the browser's `deflate-raw`) interoperates on the wire. There is no +//! length prefix: the caller is expected to frame each slice (moq-net already does). A small slice +//! can still inflate to far more than its own size, so [`Decoder::frame`] bounds each frame's output. +//! +//! A sync flush always ends in the 4-byte empty-block marker `00 00 ff ff`. That marker is fixed, so +//! [`Encoder::frame`] drops it from each slice and [`Decoder::frame`] re-appends it before inflating, +//! saving 4 bytes per frame. This is the same trick [RFC 7692] (permessage-deflate) uses for +//! WebSocket messages. +//! +//! ```ignore +//! let mut encoder = moq_flate::Encoder::new(); +//! let a = encoder.frame(b"the quick brown fox"); +//! let b = encoder.frame(b"the quick brown dog"); // smaller: reuses the window +//! +//! let mut decoder = moq_flate::Decoder::new(); +//! assert_eq!(decoder.frame(&a)?, &b"the quick brown fox"[..]); +//! assert_eq!(decoder.frame(&b)?, &b"the quick brown dog"[..]); +//! ``` +//! +//! [RFC 1951]: https://www.rfc-editor.org/rfc/rfc1951.html +//! [RFC 7692]: https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1 + +use bytes::Bytes; +use flate2::{Compress, Decompress, FlushCompress, FlushDecompress, Status}; + +/// The default DEFLATE level ([`Encoder::new`]): zlib's own default, a good size/speed balance for +/// the small, repetitive payloads this targets. +pub const DEFAULT_LEVEL: u32 = 6; + +/// The default per-frame decompressed-size cap ([`Decoder::new`]): 64 MiB. +pub const DEFAULT_MAX_FRAME_SIZE: u64 = 64 * 1024 * 1024; + +/// The trailing bytes of a DEFLATE sync flush, stripped on the wire and re-appended to decode. +const SYNC_FLUSH_TAIL: [u8; 4] = [0x00, 0x00, 0xff, 0xff]; + +/// Scratch buffer size for the streaming (de)compress loops. +const CHUNK: usize = 8 * 1024; + +/// Errors produced while decoding a frame. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// A frame could not be decoded (malformed or truncated stream, or fed out of order). + #[error("decompression failed")] + Decompress, + + /// A frame's decompressed size exceeded the configured limit (zip-bomb guard). + #[error("decompressed frame exceeded {0} bytes")] + TooLarge(u64), +} + +/// A [`Result`](std::result::Result) using this crate's [`Error`]. +pub type Result = std::result::Result; + +/// Encodes a stream's frame payloads into one shared DEFLATE window, one self-delimited slice per +/// frame. Hold one per stream; create a fresh one for each independent stream. +pub struct Encoder(Compress); + +impl Encoder { + /// Start a fresh encoder with a cold window at [`DEFAULT_LEVEL`]. + pub fn new() -> Self { + Self::with_level(DEFAULT_LEVEL) + } + + /// Start a fresh encoder with a cold window at the given DEFLATE level (`0..=9`; higher is + /// smaller and slower). Values above `9` are clamped. + pub fn with_level(level: u32) -> Self { + // `false`: raw DEFLATE, no zlib header/trailer, matching `deflate-raw` on the browser side. + Self(Compress::new(flate2::Compression::new(level.min(9)), false)) + } + + /// Compress the next frame's `payload`, returning its slice of the stream: the DEFLATE bytes minus + /// the fixed sync-flush marker. Empty in yields empty out. Later frames reuse earlier ones as + /// context, so slices must be produced (and later decoded) in frame order. + pub fn frame(&mut self, payload: &[u8]) -> Bytes { + if payload.is_empty() { + return Bytes::new(); + } + + let mut out = Vec::with_capacity(payload.len() / 2 + 16); + let mut tmp = [0u8; CHUNK]; + let mut input = payload; + + // Drive the stream with a sync flush so this frame's slice is self-delimited (byte-aligned, + // window retained). The classic zlib loop: keep going while the output buffer fills up. + loop { + let before_in = self.0.total_in(); + let before_out = self.0.total_out(); + self.0.compress(input, &mut tmp, FlushCompress::Sync).expect("deflate"); + let consumed = (self.0.total_in() - before_in) as usize; + let produced = (self.0.total_out() - before_out) as usize; + out.extend_from_slice(&tmp[..produced]); + input = &input[consumed..]; + if produced < tmp.len() { + break; + } + } + + // Drop the fixed sync-flush marker; the decoder re-appends it (see the module docs). + debug_assert!( + out.ends_with(&SYNC_FLUSH_TAIL), + "a sync flush must end in the deflate marker" + ); + out.truncate(out.len() - SYNC_FLUSH_TAIL.len()); + Bytes::from(out) + } +} + +impl Default for Encoder { + fn default() -> Self { + Self::new() + } +} + +/// Decodes a stream's frame slices back into the original payloads. Hold one per stream; feed slices +/// in frame order (each frame builds on the earlier ones). +pub struct Decoder { + inner: Decompress, + max_frame_size: u64, +} + +impl Decoder { + /// Start a fresh decoder with a cold window and the [`DEFAULT_MAX_FRAME_SIZE`] cap. + pub fn new() -> Self { + Self::with_max_frame_size(DEFAULT_MAX_FRAME_SIZE) + } + + /// Start a fresh decoder with a cold window and a custom per-frame decompressed-size cap. + /// + /// A malicious or buggy peer could send a tiny slice that inflates hugely, so [`frame`](Self::frame) + /// stops and returns [`Error::TooLarge`] once a single frame's output would exceed `max_frame_size`. + pub fn with_max_frame_size(max_frame_size: u64) -> Self { + // `false`: raw DEFLATE, matching the encoder. + Self { + inner: Decompress::new(false), + max_frame_size, + } + } + + /// Decompress the next frame's `slice` back into its payload. + /// + /// An empty slice yields an empty payload. Returns [`Error::TooLarge`] if the frame inflates past + /// the configured cap (checked as output is produced, not from any declared size), and + /// [`Error::Decompress`] on malformed input. + pub fn frame(&mut self, slice: &[u8]) -> Result { + if slice.is_empty() { + return Ok(Bytes::new()); + } + + let mut out = Vec::new(); + let mut tmp = [0u8; CHUNK]; + + // Feed the wire slice followed by the re-appended sync-flush marker, which delimits the frame + // and flushes its last bytes out of the inflate buffer. + for segment in [slice, &SYNC_FLUSH_TAIL] { + let mut input = segment; + loop { + let before_in = self.inner.total_in(); + let before_out = self.inner.total_out(); + let status = self + .inner + .decompress(input, &mut tmp, FlushDecompress::Sync) + .map_err(|_| Error::Decompress)?; + let consumed = (self.inner.total_in() - before_in) as usize; + let produced = (self.inner.total_out() - before_out) as usize; + // Bound the inflated output as it is produced; a tiny slice can expand enormously. + if out.len() as u64 + produced as u64 > self.max_frame_size { + return Err(Error::TooLarge(self.max_frame_size)); + } + out.extend_from_slice(&tmp[..produced]); + input = &input[consumed..]; + + // Move to the next segment once this one is drained and the buffer wasn't saturated. The + // no-progress guard avoids spinning when the marker needs no further output. + if matches!(status, Status::StreamEnd) || (input.is_empty() && produced < tmp.len()) { + break; + } + if consumed == 0 && produced == 0 { + break; + } + } + } + + Ok(Bytes::from(out)) + } +} + +impl Default for Decoder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod test { + use super::*; + + /// Round-trip a sequence of frames through an encoder/decoder pair. + fn roundtrip(frames: &[&[u8]]) -> Vec> { + let mut enc = Encoder::new(); + let slices: Vec = frames.iter().map(|f| enc.frame(f)).collect(); + + let mut dec = Decoder::new(); + slices.iter().map(|s| dec.frame(s).unwrap().to_vec()).collect() + } + + #[test] + fn stream_roundtrip() { + let frames: &[&[u8]] = &[b"the quick brown fox", b"the quick brown dog", b"the lazy fox"]; + let got = roundtrip(frames); + for (a, b) in frames.iter().zip(&got) { + assert_eq!(*a, b.as_slice()); + } + } + + #[test] + fn empty_frames_roundtrip() { + assert!(Encoder::new().frame(b"").is_empty()); + assert!(Decoder::new().frame(b"").unwrap().is_empty()); + } + + #[test] + fn cross_frame_context_shrinks() { + // A later frame identical to an earlier one compresses to far fewer bytes once the window + // holds the earlier copy: this is the whole point of a shared stream. + let payload = b"Media over QUIC delivers real-time latency at massive scale.".repeat(6); + let mut enc = Encoder::new(); + let first = enc.frame(&payload); + let second = enc.frame(&payload); + assert!( + second.len() < first.len(), + "repeat frame {} should be smaller than first {}", + second.len(), + first.len() + ); + } + + #[test] + fn frame_larger_than_chunk_roundtrips() { + // High-entropy data barely compresses, so its slice exceeds the streaming `CHUNK` scratch + // buffer and the (de)compress loops must iterate. Verify it still round-trips byte for byte. + let mut state: u64 = 0x9E37_79B9_7F4A_7C15; + let payload: Vec = (0..64 * 1024) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 56) as u8 + }) + .collect(); + + let mut enc = Encoder::new(); + let slice = enc.frame(&payload); + assert!(slice.len() > CHUNK, "slice {} should exceed CHUNK {CHUNK}", slice.len()); + + let mut dec = Decoder::new(); + assert_eq!(dec.frame(&slice).unwrap(), Bytes::from(payload)); + } + + #[test] + fn decompress_rejects_garbage() { + let mut dec = Decoder::new(); + assert_eq!(dec.frame(b"not a deflate stream at all"), Err(Error::Decompress)); + } + + #[test] + fn enforces_max_frame_size() { + // A tiny slice of a highly compressible payload inflates past a small cap. + let payload = vec![0u8; 1024]; + let slice = Encoder::new().frame(&payload); + + let mut dec = Decoder::with_max_frame_size(512); + assert_eq!(dec.frame(&slice), Err(Error::TooLarge(512))); + } + + #[test] + fn custom_level_roundtrips() { + let payload = b"compress me at maximum effort".repeat(8); + let mut enc = Encoder::with_level(9); + let slice = enc.frame(&payload); + let mut dec = Decoder::new(); + assert_eq!(dec.frame(&slice).unwrap(), Bytes::from(payload)); + } +} diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 8cbeac345..9063aed5e 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.4" +version = "0.1.0" edition = "2024" rust-version.workspace = true @@ -17,9 +17,9 @@ doctest = false [dependencies] bytes = "1" -flate2 = { workspace = true } json-patch = "4" kio = { workspace = true } +moq-flate = { workspace = true } moq-net = { workspace = true } serde = { workspace = true } serde_json = "1" diff --git a/rs/moq-json/examples/telemetry.rs b/rs/moq-json/examples/telemetry.rs new file mode 100644 index 000000000..281839f89 --- /dev/null +++ b/rs/moq-json/examples/telemetry.rs @@ -0,0 +1,210 @@ +//! Measure wire savings of group-scoped DEFLATE + snapshot/delta on a telemetry stream. +//! +//! Simulates a realistic device telemetry blob that ticks once per second: most of the document +//! is static (identity, config, geo) while a handful of gauges and counters change each tick. This +//! is exactly the shape `moq-json` targets, so it shows the snapshot/delta and compression knobs +//! pulling in the same direction. +//! +//! Run with: `cargo run -p moq-json --example telemetry` + +use std::task::Poll; + +use moq_json::{ConsumerConfig, Producer, ProducerConfig}; +use serde_json::{Value, json}; + +/// One second of telemetry for a fleet device: a big static core plus a few moving numbers. +fn telemetry(tick: u64) -> Value { + // A slow drift so consecutive ticks differ by a little, like real sensors. + let t = tick as f64; + let lat = 37.7749 + (t * 0.0001).sin() * 0.01; + let lon = -122.4194 + (t * 0.0001).cos() * 0.01; + + json!({ + "device": { + "id": "veh-4417-a2", + "model": "Sentinel X2", + "firmware": "4.18.2-rc1", + "serial": "SNX2-0000-4417-A2C9", + "region": "us-west-2", + "fleet": "logistics-prod", + "tags": ["cold-chain", "long-haul", "priority"], + }, + "config": { + "sample_hz": 1, + "upload_hz": 1, + "geofence": "bay-area", + "thresholds": { "temp_c": 8.0, "humidity": 85, "shock_g": 3.5, "battery_pct": 15 }, + "contacts": ["ops@example.com", "fleet@example.com"], + }, + "ts": 1_700_000_000 + tick, + "uptime_s": tick, + "location": { + "lat": (lat * 1e6).round() / 1e6, + "lon": (lon * 1e6).round() / 1e6, + "alt_m": 12 + (tick % 5), + "heading": (tick * 7) % 360, + "speed_kph": 40 + (tick % 25), + "fix": "3d", + "sats": 9 + (tick % 3), + }, + "sensors": { + "temp_c": (4.0 + (t * 0.05).sin() * 1.5 * 100.0).round() / 100.0, + "humidity": 60 + (tick % 10), + "shock_g": (((t * 0.3).sin().abs()) * 100.0).round() / 100.0, + "door_open": tick % 30 == 0, + }, + "power": { + "battery_pct": 100 - (tick / 6) % 100, + "charging": false, + "voltage_mv": 12_400 - (tick % 50) as i64, + "current_ma": 850 + (tick % 120) as i64, + }, + "network": { + "rssi_dbm": -70 - (tick % 15) as i64, + "type": "lte", + "bytes_up": 1_024 * tick, + "bytes_down": 256 * tick, + "latency_ms": 35 + (tick % 40), + }, + "counters": { + "events": tick, + "errors": tick / 50, + "reconnects": tick / 120, + }, + }) +} + +/// Total wire bytes of every frame across every group for a full run under `config`. +fn wire_bytes(config: ProducerConfig, ticks: u64) -> usize { + let track = moq_net::Track::new("telemetry").produce(); + let consumer = track.consume(); + let mut producer = Producer::::new(track, config); + + for tick in 0..ticks { + producer.update(&telemetry(tick)).unwrap(); + } + producer.finish().unwrap(); + + // Drain the raw stored frames (compressed if the producer compressed them) and sum their sizes. + let waiter = kio::Waiter::noop(); + let mut total = 0; + let mut track = consumer; + while let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) { + while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) { + total += frame.len(); + } + } + total +} + +/// Drive a producer and a live consumer in lockstep, asserting that EVERY tick reconstructs to the +/// exact input value after decompression and delta application (not just the final one). +fn verify(producer_config: ProducerConfig, ticks: u64) { + let track = moq_net::Track::new("telemetry").produce(); + let consumer = track.consume(); + let mut producer = Producer::::new(track, producer_config.clone()); + + let mut consumer_config = ConsumerConfig::default(); + consumer_config.compression = producer_config.compression; + let mut consumer = moq_json::Consumer::::new(consumer, consumer_config); + let waiter = kio::Waiter::noop(); + + for tick in 0..ticks { + let expected = telemetry(tick); + producer.update(&expected).unwrap(); + // The producer emits exactly one frame per update, so the live consumer yields exactly one + // reconstructed value: it must match the input byte-for-byte after decompression + patching. + match consumer.poll_next(&waiter) { + Poll::Ready(Ok(Some(value))) => assert_eq!(value, expected, "tick {tick} reconstruction mismatch"), + other => panic!("tick {tick}: expected a value, got {other:?}"), + } + } + producer.finish().unwrap(); + + // Drain: nothing left and the stream ends cleanly. + assert!( + matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(None))), + "stream did not end cleanly" + ); +} + +/// A consumer that joins only after the whole stream exists must still rebuild the latest value from +/// the newest group's snapshot + deltas. For the compressed path this exercises the lazy decoder +/// replaying the group's already-stored slices to warm its window before decoding the final frame. +/// +/// Returns how many values the late joiner surfaced to the application: with backlog collapsing this +/// is far below `ticks`, since stale intermediate reconstructions are applied internally but skipped. +fn verify_late_joiner(producer_config: ProducerConfig, ticks: u64) -> usize { + let track = moq_net::Track::new("telemetry").produce(); + let consumer = track.consume(); + let mut producer = Producer::::new(track, producer_config.clone()); + for tick in 0..ticks { + producer.update(&telemetry(tick)).unwrap(); + } + producer.finish().unwrap(); + + let mut consumer_config = ConsumerConfig::default(); + consumer_config.compression = producer_config.compression; + let mut consumer = moq_json::Consumer::::new(consumer, consumer_config); + let waiter = kio::Waiter::noop(); + let mut last = None; + let mut yielded = 0; + while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { + last = Some(value); + yielded += 1; + } + assert_eq!( + last.as_ref(), + Some(&telemetry(ticks - 1)), + "late joiner reconstruction mismatch" + ); + yielded +} + +fn cfg(delta_ratio: u32, compression: bool) -> ProducerConfig { + let mut config = ProducerConfig::default(); + config.delta_ratio = delta_ratio; + config.compression = compression; + config +} + +fn main() { + const TICKS: u64 = 60; + + // Raw baseline: every tick as a full JSON blob, no moq-json framing tricks. + let raw: usize = (0..TICKS) + .map(|t| serde_json::to_vec(&telemetry(t)).unwrap().len()) + .sum(); + let snapshot_len = serde_json::to_vec(&telemetry(0)).unwrap().len(); + + let combos = [ + ("snapshot-per-group, plaintext", cfg(0, false)), + ("snapshot-per-group, deflate ", cfg(0, true)), + ("snapshot+delta, plaintext", cfg(8, false)), + ("snapshot+delta, deflate ", cfg(8, true)), + ]; + + println!("Telemetry stream: {TICKS} ticks, ~{snapshot_len} bytes per snapshot\n"); + println!("Raw JSON (one blob per tick): {raw:>8} bytes (baseline)\n"); + + println!("{:<32} {:>10} {:>10} {:>9}", "config", "wire", "vs raw", "saved"); + println!("{}", "-".repeat(64)); + for (name, config) in combos.clone() { + verify(config.clone(), TICKS); + verify_late_joiner(config.clone(), TICKS); + let bytes = wire_bytes(config, TICKS); + let pct = 100.0 * bytes as f64 / raw as f64; + let saved = 100.0 - pct; + println!("{name:<32} {bytes:>8} B {pct:>8.1}% {saved:>7.1}%"); + } + + println!("\nVerified: every tick reconstructs exactly (live + late joiner) for all 4 configs."); + + // Late-joiner collapse: a consumer joining after all {TICKS} ticks exist gets the head in one + // step, not a replay of every superseded state. + println!("\nLate joiner: values surfaced to the app (was {TICKS} per-frame, now collapsed):"); + for (name, config) in combos { + let yielded = verify_late_joiner(config, TICKS); + println!(" {name:<32} {yielded:>3} value(s)"); + } +} diff --git a/rs/moq-json/src/compression.rs b/rs/moq-json/src/compression.rs deleted file mode 100644 index 5bc6a9303..000000000 --- a/rs/moq-json/src/compression.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! Group-scoped DEFLATE compression for the JSON frame stream. -//! -//! Within a group the frame payloads form a single raw DEFLATE ([RFC 1951]) stream, sync-flushed -//! at each frame boundary so every frame carries its own self-delimited slice while later frames -//! reuse the earlier ones as context (a snapshot followed by deltas compresses far better than -//! each frame alone). The [`Encoder`]/[`Decoder`] hold that per-group state; both are recreated at -//! every group boundary. -//! -//! This is plain raw DEFLATE with a `Z_SYNC_FLUSH` after each frame, so a browser (`@moq/json`) -//! peer interoperates on the wire using the same primitive (zlib's sync flush). moq-net already -//! frames each slice, so there's no length prefix. A small slice can still inflate to far more than -//! its own size, so [`Decoder::frame`] bounds each frame's output at [`MAX_DECOMPRESSED_FRAME`]. -//! -//! A sync flush always ends in the 4-byte empty-block marker `00 00 ff ff`. That marker is fixed, -//! so [`Encoder::frame`] drops it from each slice and [`Decoder::frame`] re-appends it before -//! inflating, saving 4 bytes per frame. This is the same trick [RFC 7692] (permessage-deflate) -//! uses for WebSocket messages. -//! -//! [RFC 1951]: https://www.rfc-editor.org/rfc/rfc1951.html -//! [RFC 7692]: https://www.rfc-editor.org/rfc/rfc7692.html#section-7.2.1 - -use bytes::Bytes; -use flate2::{Compress, Decompress, FlushCompress, FlushDecompress, Status}; - -use crate::{Error, Result}; - -/// DEFLATE level for the frame stream: zlib's own default, a good size/speed balance for the small, -/// repetitive payloads this targets. -const LEVEL: u32 = 6; - -/// The trailing bytes of a DEFLATE sync flush, stripped on the wire and re-appended to decode. -const SYNC_FLUSH_TAIL: [u8; 4] = [0x00, 0x00, 0xff, 0xff]; - -/// Maximum decompressed size of a single frame. -/// -/// A malicious publisher could otherwise send a tiny slice that inflates hugely, so -/// [`Decoder::frame`] stops and returns [`Error::TooLarge`] rather than allocating without limit. -const MAX_DECOMPRESSED_FRAME: u64 = 64 * 1024 * 1024; - -/// Scratch buffer size for the streaming (de)compress loops. -const CHUNK: usize = 8 * 1024; - -/// Encodes a group's frame payloads into one shared DEFLATE stream, one self-delimited slice per -/// frame. Hold one per group; the stream is recreated at each group boundary. -pub(crate) struct Encoder(Compress); - -impl Encoder { - /// Start a fresh per-group encoder with a cold window. - pub(crate) fn new() -> Self { - // `false`: raw DEFLATE, no zlib header/trailer, matching `deflate-raw` on the browser side. - Self(Compress::new(flate2::Compression::new(LEVEL), false)) - } - - /// Compress the next frame's `payload`, returning its slice of the group stream: the DEFLATE - /// bytes minus the fixed sync-flush marker. Empty in yields empty out. Later frames reuse earlier - /// ones as context, so slices must be produced (and later decoded) in frame order. - pub(crate) fn frame(&mut self, payload: &[u8]) -> Bytes { - if payload.is_empty() { - return Bytes::new(); - } - - let mut out = Vec::with_capacity(payload.len() / 2 + 16); - let mut tmp = [0u8; CHUNK]; - let mut input = payload; - - // Drive the stream with a sync flush so this frame's slice is self-delimited (byte-aligned, - // window retained). The classic zlib loop: keep going while the output buffer fills up. - loop { - let before_in = self.0.total_in(); - let before_out = self.0.total_out(); - self.0.compress(input, &mut tmp, FlushCompress::Sync).expect("deflate"); - let consumed = (self.0.total_in() - before_in) as usize; - let produced = (self.0.total_out() - before_out) as usize; - out.extend_from_slice(&tmp[..produced]); - input = &input[consumed..]; - if produced < tmp.len() { - break; - } - } - - // Drop the fixed sync-flush marker; the decoder re-appends it (see the module docs). - debug_assert!( - out.ends_with(&SYNC_FLUSH_TAIL), - "a sync flush must end in the deflate marker" - ); - out.truncate(out.len() - SYNC_FLUSH_TAIL.len()); - Bytes::from(out) - } -} - -/// Decodes a group's frame slices back into the original payloads. Hold one per group; feed slices -/// in frame order (each frame builds on the earlier ones). -pub(crate) struct Decoder(Decompress); - -impl Decoder { - /// Start a fresh per-group decoder with a cold window. - pub(crate) fn new() -> Self { - // `false`: raw DEFLATE, matching the encoder. - Self(Decompress::new(false)) - } - - /// Decompress the next frame's `slice` back into its payload. - /// - /// An empty slice yields an empty payload. Returns [`Error::TooLarge`] if the frame inflates past - /// the per-frame bound (checked as output is produced, not from any declared size), and - /// [`Error::Decompress`] on malformed input. - pub(crate) fn frame(&mut self, slice: &[u8]) -> Result { - if slice.is_empty() { - return Ok(Bytes::new()); - } - - let mut out = Vec::new(); - let mut tmp = [0u8; CHUNK]; - - // Feed the wire slice followed by the re-appended sync-flush marker, which delimits the frame - // and flushes its last bytes out of the inflate buffer. - for segment in [slice, &SYNC_FLUSH_TAIL] { - let mut input = segment; - loop { - let before_in = self.0.total_in(); - let before_out = self.0.total_out(); - let status = self - .0 - .decompress(input, &mut tmp, FlushDecompress::Sync) - .map_err(|_| Error::Decompress)?; - let consumed = (self.0.total_in() - before_in) as usize; - let produced = (self.0.total_out() - before_out) as usize; - // Bound the inflated output as it is produced; a tiny slice can expand enormously. - if out.len() as u64 + produced as u64 > MAX_DECOMPRESSED_FRAME { - return Err(Error::TooLarge(MAX_DECOMPRESSED_FRAME)); - } - out.extend_from_slice(&tmp[..produced]); - input = &input[consumed..]; - - // Move to the next segment once this one is drained and the buffer wasn't saturated. The - // no-progress guard avoids spinning when the marker needs no further output. - if matches!(status, Status::StreamEnd) || (input.is_empty() && produced < tmp.len()) { - break; - } - if consumed == 0 && produced == 0 { - break; - } - } - } - - Ok(Bytes::from(out)) - } -} - -#[cfg(test)] -mod test { - use super::*; - - /// Round-trip a sequence of frames through a group encoder/decoder pair. - fn roundtrip(frames: &[&[u8]]) -> Vec> { - let mut enc = Encoder::new(); - let slices: Vec = frames.iter().map(|f| enc.frame(f)).collect(); - - let mut dec = Decoder::new(); - slices.iter().map(|s| dec.frame(s).unwrap().to_vec()).collect() - } - - #[test] - fn group_roundtrip() { - let frames: &[&[u8]] = &[b"the quick brown fox", b"the quick brown dog", b"the lazy fox"]; - let got = roundtrip(frames); - for (a, b) in frames.iter().zip(&got) { - assert_eq!(*a, b.as_slice()); - } - } - - #[test] - fn empty_frames_roundtrip() { - assert!(Encoder::new().frame(b"").is_empty()); - assert!(Decoder::new().frame(b"").unwrap().is_empty()); - } - - #[test] - fn cross_frame_context_shrinks() { - // A later frame identical to an earlier one compresses to far fewer bytes once the window - // holds the earlier copy: this is the whole point of a shared per-group stream. - let payload = b"Media over QUIC delivers real-time latency at massive scale.".repeat(6); - let mut enc = Encoder::new(); - let first = enc.frame(&payload); - let second = enc.frame(&payload); - assert!( - second.len() < first.len(), - "repeat frame {} should be smaller than first {}", - second.len(), - first.len() - ); - } - - #[test] - fn frame_larger_than_chunk_roundtrips() { - // High-entropy data barely compresses, so its slice exceeds the streaming `CHUNK` scratch - // buffer and the (de)compress loops must iterate. Verify it still round-trips byte for byte. - let mut state: u64 = 0x9E37_79B9_7F4A_7C15; - let payload: Vec = (0..64 * 1024) - .map(|_| { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - (state >> 56) as u8 - }) - .collect(); - - let mut enc = Encoder::new(); - let slice = enc.frame(&payload); - assert!(slice.len() > CHUNK, "slice {} should exceed CHUNK {CHUNK}", slice.len()); - - let mut dec = Decoder::new(); - assert_eq!(dec.frame(&slice).unwrap(), Bytes::from(payload)); - } - - #[test] - fn decompress_rejects_garbage() { - let mut dec = Decoder::new(); - assert!(matches!( - dec.frame(b"not a deflate stream at all"), - Err(Error::Decompress) - )); - } -} diff --git a/rs/moq-json/src/lib.rs b/rs/moq-json/src/lib.rs index 5a1f2dd2b..cec1f9b0f 100644 --- a/rs/moq-json/src/lib.rs +++ b/rs/moq-json/src/lib.rs @@ -9,7 +9,6 @@ //! Deltas are controlled by [`ProducerConfig::delta_ratio`]. A ratio of `0` disables them, so every //! change is a fresh snapshot group, matching a plain "one JSON blob per group" track. -mod compression; mod diff; use std::marker::PhantomData; @@ -18,11 +17,11 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::task::Poll; use bytes::Bytes; +use moq_flate::{Decoder, Encoder}; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; -use crate::compression::{Decoder, Encoder}; use crate::diff::diff; /// Maximum frames (snapshot + deltas) in a single group before a new snapshot is forced. @@ -45,13 +44,9 @@ pub enum Error { #[error("json: {0}")] Json(String), - /// A compressed frame could not be decoded (malformed or truncated stream). - #[error("decompression failed")] - Decompress, - - /// A frame's decompressed size exceeded the limit (zip-bomb guard). - #[error("decompressed frame exceeded {0} bytes")] - TooLarge(u64), + /// A compressed frame could not be decoded (malformed, truncated, or oversized). + #[error(transparent)] + Flate(#[from] moq_flate::Error), } impl From for Error { @@ -438,8 +433,13 @@ impl Consumer { /// Poll for the next reconstructed value, without blocking. /// - /// Jumps to the newest group, reads its snapshot, and applies deltas in order, yielding the - /// reconstructed value after each frame. Switching to a newer group discards the older one. + /// Jumps to the newest group, reads its snapshot, and applies deltas in order. All frames already + /// buffered in the group are applied in one poll but only the resulting *latest* value is yielded: + /// the intermediate reconstructions are stale, so a late joiner (or any consumer that has fallen + /// behind) catches up to the head in a single step instead of replaying every superseded state. + /// Frames must still be decoded in order (the DEFLATE window and merge patches are sequential); + /// only the per-frame deserialize and yield are skipped. Switching to a newer group discards the + /// older one. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { // Drain to the newest group, resetting reconstruction state whenever we switch. let track_finished = loop { @@ -457,15 +457,42 @@ impl Consumer { } }; - if let Some(group) = &mut self.group { + // Apply every frame currently buffered in the group, tracking whether any moved us forward and + // whether the group is still open with nothing buffered yet (vs. exhausted). + // `poll_read_frame` returns an owned `Poll`, so the borrow of `self.group` ends before the + // match arms, leaving `apply` (and clearing the group) free to take `&mut self`. + let mut advanced = false; + let mut group_pending = false; + while let Some(group) = &mut self.group { match group.poll_read_frame(waiter)? { - Poll::Ready(Some(frame)) => return Poll::Ready(Ok(Some(self.apply(frame)?))), + Poll::Ready(Some(frame)) => { + self.apply(frame)?; + advanced = true; + } // The current group is exhausted; wait for a newer one. - Poll::Ready(None) => self.group = None, - Poll::Pending => return Poll::Pending, + Poll::Ready(None) => { + self.group = None; + break; + } + // The group is still open but has nothing buffered yet. + Poll::Pending => { + group_pending = true; + break; + } } } + if advanced { + // Deserialize once, from the head of the backlog we just drained. + return Poll::Ready(Ok(Some(self.reconstruct()?))); + } + + // An open group may still deliver frames even after the track finishes (it was appended before + // the finish), so wait on it rather than ending the stream. + if group_pending { + return Poll::Pending; + } + if track_finished { Poll::Ready(Ok(None)) } else { @@ -496,8 +523,9 @@ impl Consumer { Ok(plain) } - /// Apply one frame: frame 0 of a group is a snapshot, the rest are merge patches. - fn apply(&mut self, frame: Bytes) -> Result { + /// Apply one frame to the in-progress value: frame 0 of a group is a snapshot, the rest are merge + /// patches. Updates internal state only; call [`reconstruct`](Self::reconstruct) to materialize `T`. + fn apply(&mut self, frame: Bytes) -> Result<()> { let frame = self.decode(frame)?; if self.frames_read == 0 { self.current = Some(serde_json::from_slice(&frame)?); @@ -507,7 +535,12 @@ impl Consumer { json_patch::merge(current, &patch); } self.frames_read += 1; + Ok(()) + } + /// Materialize the current reconstructed value into `T`. Call only after at least one frame has + /// been applied in the current group. + fn reconstruct(&self) -> Result { let current = self .current .as_ref() @@ -791,6 +824,69 @@ mod test { } } + #[test] + fn open_group_pends_after_track_finish() { + // A group appended before the track finishes may still deliver frames, so the consumer must + // keep waiting on it rather than ending the stream. Regression for the backlog-collapse poll. + let mut track = moq_net::Track::new("test").produce(); + let mut group = track.append_group().unwrap(); + track.finish().unwrap(); + + let mut consumer = Consumer::::new(track.consume(), ConsumerConfig::default()); + let waiter = kio::Waiter::noop(); + + // Track is finished but the open group is empty: pending, not end-of-stream. + assert!(matches!(consumer.poll_next(&waiter), Poll::Pending)); + + group + .write_frame(Bytes::from(serde_json::to_vec(&json!({ "a": 1 })).unwrap())) + .unwrap(); + group.finish().unwrap(); + + match consumer.poll_next(&waiter) { + Poll::Ready(Ok(Some(value))) => assert_eq!(value, json!({ "a": 1 })), + other => panic!("expected the catalog value, got {other:?}"), + } + } + + #[test] + fn late_joiner_collapses_backlog_to_latest() { + // A whole group's worth of snapshot + deltas is buffered before the consumer reads. It should + // apply them all but yield only the latest value once, not replay every superseded state. + let (mut producer, track) = producer(cfg(100)); + for n in 0..=20 { + producer.update(&json!({ "n": n })).unwrap(); + } + producer.finish().unwrap(); + + // One group (ratio is generous), so a single poll drains the backlog into one yield. + assert_eq!(track.latest(), Some(0)); + let values = drain(track); + assert_eq!( + values, + vec![json!({ "n": 20 })], + "backlog should collapse to the latest value" + ); + } + + #[test] + fn compressed_late_joiner_collapses_backlog_to_latest() { + // Same collapse, exercising the lazy decoder replaying the group's slices to warm its window. + let (mut producer, track) = producer(cfg_deflate(100)); + for n in 0..=20 { + producer.update(&json!({ "n": n })).unwrap(); + } + producer.finish().unwrap(); + + assert_eq!(track.latest(), Some(0)); + let values = drain_with(deflate_consumer(track)); + assert_eq!( + values, + vec![json!({ "n": 20 })], + "compressed backlog should collapse to the latest" + ); + } + #[test] fn compressed_snapshot_per_group_roundtrips() { let (mut producer, track) = producer(cfg_deflate(0)); From bdad42c33d01141b4d3b10d6861e9de9c2cf30d8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 24 Jun 2026 12:56:14 -0700 Subject: [PATCH 05/34] feat(moq-relay): reuse client TLS for outbound auth HTTP; make --client-tls-* flags consistent (#1901) Co-authored-by: Claude --- CLAUDE.md | 10 +++ doc/bin/relay/auth.md | 11 +++ doc/concept/standard/interop.md | 2 +- doc/setup/windows.md | 4 +- rs/moq-bench/README.md | 2 +- rs/moq-native/src/client.rs | 42 ++++++++-- rs/moq-native/src/tls.rs | 138 ++++++++++++++++++++++++++------ rs/moq-relay/src/auth.rs | 80 ++++++++++++++---- rs/moq-relay/src/config.rs | 2 +- rs/moq-relay/src/main.rs | 2 +- rs/moq-relay/tests/smoke.rs | 5 +- 11 files changed, 243 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 40185e245..dd7d41283 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,16 @@ This root file holds only cross-cutting rules that apply everywhere (writing sty - Write the way you'd say it out loud, not the way a doc generator would. One short line is almost always enough. Skip throat-clearing like "This function is responsible for...". - Comments must reflect the **current** state of the code, not its history. Don't write "X no longer does Y" or "this used to cascade". Describe what the code does today, or delete the comment. Migration context belongs in commit messages and PR descriptions, where it ages with the change rather than rotting in the source. +## Deprecation + +Don't document deprecated flags, options, or APIs. User-facing docs (`/doc`), `--help`, and doc comments should describe only the current/canonical surface, so a reader is steered to the right thing and never learns the dead one. Keep the deprecated path *working* but invisible: + +- A deprecated CLI flag stays a hidden alias (clap `alias = "..."`, or a separate `#[arg(..., hide = true)]` when it needs its own deprecation warning). No `--help` entry, no "deprecated, use X" note in the doc comment. +- A deprecated public item gets `#[doc(hidden)]` (Rust) / `@internal` or omission (JS) so it drops off the published docs. +- Remove the example invocations and prose that mention it from `/doc`. + +The rename/removal rationale lives in the commit message and PR description, not in docs that users read. A runtime warning when someone *uses* the deprecated path is fine (it fires on use, it isn't documentation); a standing note that advertises the dead name is not. + ## AI Attribution LLM-authored prose visible to humans (PR descriptions, PR comments, review replies) should end with `(Written by Claude)` or similar. Do **not** tag code comments, doc comments, or `/doc` pages: source markers rot. Commit attribution lives in the `Co-Authored-By` trailer, not the commit body. diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index c4655ae9c..4f25ead0f 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -171,6 +171,17 @@ auth_api = "https://api.moq.dev/cluster/auth" Unlike the standalone flags, the unified call **fails closed**: any network error, non-2xx status, or unparseable response rejects the connection. The verifying key itself comes from this call, so there is no safe fallback; the endpoint's `Cache-Control` softens transient failures. This applies to mTLS peers as well, including root (`/`) connections such as cluster peers: when an auth API is configured it is the source of truth for every connection (so it can alias and tier the root too), and a failed lookup rejects the connection so the peer reconnects and self-heals once the API recovers. The only fail-open case is when **no** auth API is configured, where the client certificate is the sole credential and the path is used unchanged. +### Authenticating the relay to the auth API + +The outbound HTTP the relay makes for auth (`--auth-api` requests and JWK fetches) reuses the cluster client's TLS configuration. The same `--client-tls-cert` / `--client-tls-key` the relay presents when dialing cluster peers also identifies it to the auth API, and `--client-tls-root` trusts a private CA on the endpoint (env `MOQ_CLIENT_TLS_*`, or `[client.tls]` in TOML). So an auth API can require mTLS and recognize the relay by the same certificate it uses for clustering. + +```toml +[client.tls] +cert = "/etc/moq/relay-client.pem" +key = "/etc/moq/relay-client.key" +root = ["/etc/moq/auth-api-ca.pem"] +``` + ## Supported Algorithms ### Symmetric (HMAC) diff --git a/doc/concept/standard/interop.md b/doc/concept/standard/interop.md index ca734bdd4..fa82e51d2 100644 --- a/doc/concept/standard/interop.md +++ b/doc/concept/standard/interop.md @@ -43,7 +43,7 @@ If it plays, you interop. That's the whole test. - **`SUBSCRIBE_NAMESPACE` is required.** The subscriber discovers broadcasts by sending `SUBSCRIBE_NAMESPACE` and waiting for a matching announce, so your relay must support it. The publisher announces with `PUBLISH_NAMESPACE`. -- **Self-signed or expired cert?** Add `--tls-disable-verify`. +- **Self-signed or expired cert?** Add `--client-tls-disable-verify`. - **Subscriber sees nothing?** If your relay doesn't replay existing announcements, start the subscriber before the publisher. - **Verbose logs:** prefix with `RUST_LOG=info,moq_net=debug`. It prints the diff --git a/doc/setup/windows.md b/doc/setup/windows.md index 950f68c9a..b00430bcf 100644 --- a/doc/setup/windows.md +++ b/doc/setup/windows.md @@ -62,10 +62,10 @@ REM Grab the relay's certificate fingerprint for /f %f in ('curl -s http://localhost:4443/certificate.sha256') do set FP=%f REM Publish -cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --tls-fingerprint %FP% publish +cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --client-tls-fingerprint %FP% publish REM Subscribe (separate terminal) -cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --tls-fingerprint %FP% subscribe +cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --client-tls-fingerprint %FP% subscribe ``` The subscriber prints the current time once per second, sourced from the diff --git a/rs/moq-bench/README.md b/rs/moq-bench/README.md index 109853044..1de608f7b 100644 --- a/rs/moq-bench/README.md +++ b/rs/moq-bench/README.md @@ -74,7 +74,7 @@ table (`fps = { min = 24, max = 60 }`). | `--duration` | | Stop after this long (runs until interrupted otherwise) | | `--report` | | How often to log throughput stats | -Client TLS/QUIC flags (`--tls-disable-verify`, `--client-bind`, ...) come from +Client TLS/QUIC flags (`--client-tls-disable-verify`, `--client-bind`, ...) come from `moq-native` and behave the same as in `moq-cli` and `moq-relay`. ## Presets diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index 01789a83b..05fda2457 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -466,29 +466,59 @@ mod tests { let mut config: ClientConfig = toml::from_str(toml).unwrap(); assert_eq!(config.tls.disable_verify, Some(true)); - // Simulate: TOML loaded, then CLI args re-applied (no --tls-disable-verify flag). + // Simulate: TOML loaded, then CLI args re-applied (no --client-tls-disable-verify flag). config.update_from(["test"]); assert_eq!(config.tls.disable_verify, Some(true)); } #[test] fn test_cli_disable_verify_flag() { - let config = ClientConfig::parse_from(["test", "--tls-disable-verify"]); + let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify"]); assert_eq!(config.tls.disable_verify, Some(true)); } #[test] fn test_cli_disable_verify_explicit_false() { - let config = ClientConfig::parse_from(["test", "--tls-disable-verify=false"]); + let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=false"]); assert_eq!(config.tls.disable_verify, Some(false)); } #[test] fn test_cli_disable_verify_explicit_true() { - let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true"]); + let config = ClientConfig::parse_from(["test", "--client-tls-disable-verify=true"]); assert_eq!(config.tls.disable_verify, Some(true)); } + #[test] + fn test_cli_deprecated_tls_flags_fold_into_canonical() { + // The bare --tls-* forms are deprecated. They parse into a hidden field and + // fold into the canonical values via the effective_* accessors build() uses, + // so they keep working without touching the public Client fields. + let config = ClientConfig::parse_from(["test", "--tls-disable-verify=true", "--tls-fingerprint", "abcd1234"]); + assert_eq!( + config.tls.disable_verify, None, + "deprecated flag must not set the canonical field" + ); + assert_eq!(config.tls.effective_disable_verify(), Some(true)); + assert_eq!(config.tls.effective_fingerprint(), vec!["abcd1234"]); + } + + #[test] + fn test_canonical_tls_flag_wins_over_deprecated() { + // Both spellings given: canonical wins for scalar options, vecs concatenate. + let config = ClientConfig::parse_from([ + "test", + "--client-tls-disable-verify=false", + "--tls-disable-verify=true", + "--client-tls-fingerprint", + "aaaa", + "--tls-fingerprint", + "bbbb", + ]); + assert_eq!(config.tls.effective_disable_verify(), Some(false)); + assert_eq!(config.tls.effective_fingerprint(), vec!["aaaa", "bbbb"]); + } + #[test] fn test_cli_no_disable_verify() { let config = ClientConfig::parse_from(["test"]); @@ -504,7 +534,7 @@ mod tests { let mut config: ClientConfig = toml::from_str(toml).unwrap(); assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]); - // Simulate: TOML loaded, then CLI args re-applied (no --tls-fingerprint flag). + // Simulate: TOML loaded, then CLI args re-applied (no --client-tls-fingerprint flag). config.update_from(["test"]); assert_eq!(config.tls.fingerprint, vec!["abcd1234", "ef567890"]); } @@ -521,7 +551,7 @@ mod tests { #[test] fn test_cli_fingerprint() { - let config = ClientConfig::parse_from(["test", "--tls-fingerprint", "abcd1234"]); + let config = ClientConfig::parse_from(["test", "--client-tls-fingerprint", "abcd1234"]); assert_eq!(config.tls.fingerprint, vec!["abcd1234"]); } diff --git a/rs/moq-native/src/tls.rs b/rs/moq-native/src/tls.rs index 9855b3e02..80acb682f 100644 --- a/rs/moq-native/src/tls.rs +++ b/rs/moq-native/src/tls.rs @@ -36,7 +36,7 @@ pub enum Error { EmptyRoots(PathBuf), #[error( - "no trusted roots: provide --tls-root, enable --tls-system-roots, or use --tls-fingerprint / --tls-disable-verify" + "no trusted roots: provide --client-tls-root, enable --client-tls-system-roots, or use --client-tls-fingerprint / --client-tls-disable-verify" )] NoRoots, @@ -47,7 +47,7 @@ pub enum Error { FingerprintLength(usize), #[error( - "--tls-fingerprint cannot be combined with --tls-root or --tls-system-roots: fingerprint pinning bypasses CA verification" + "--client-tls-fingerprint cannot be combined with --client-tls-root or --client-tls-system-roots: fingerprint pinning bypasses CA verification" )] FingerprintWithRoots, @@ -113,23 +113,23 @@ pub struct Client { /// /// These roots are added on top of the system roots. By default the system /// roots are only loaded when no custom root is given, so passing a root - /// replaces them; set `--tls-system-roots` to trust both (e.g. to reach a + /// replaces them; set `--client-tls-system-roots` to trust both (e.g. to reach a /// local relay with a private CA and a remote one with a public CA). #[serde(skip_serializing_if = "Vec::is_empty")] - #[arg(id = "tls-root", long = "tls-root", env = "MOQ_CLIENT_TLS_ROOT")] + #[arg(id = "client-tls-root", long = "client-tls-root", env = "MOQ_CLIENT_TLS_ROOT")] #[serde_as(as = "serde_with::OneOrMany<_>")] pub root: Vec, /// Also trust the platform's native root certificates. /// - /// Defaults to enabled only when no `--tls-root` is given. Set it explicitly - /// to trust the system roots alongside any custom roots, or set it to false - /// to trust only the custom roots. Trusting neither (no custom root and - /// system roots disabled) is rejected, since verification could never pass. + /// Defaults to enabled only when no `--client-tls-root` is given. Set it + /// explicitly to trust the system roots alongside any custom roots, or set it + /// to false to trust only the custom roots. Trusting neither (no custom root + /// and system roots disabled) is rejected, since verification could never pass. #[serde(skip_serializing_if = "Option::is_none")] #[arg( - id = "tls-system-roots", - long = "tls-system-roots", + id = "client-tls-system-roots", + long = "client-tls-system-roots", env = "MOQ_CLIENT_TLS_SYSTEM_ROOTS", default_missing_value = "true", num_args = 0..=1, @@ -149,7 +149,11 @@ pub struct Client { /// This value can be provided multiple times to accept any of several fingerprints (e.g. /// across a certificate rotation). In config files, accepts either a single string or a TOML array. #[serde(skip_serializing_if = "Vec::is_empty")] - #[arg(id = "tls-fingerprint", long = "tls-fingerprint", env = "MOQ_CLIENT_TLS_FINGERPRINT")] + #[arg( + id = "client-tls-fingerprint", + long = "client-tls-fingerprint", + env = "MOQ_CLIENT_TLS_FINGERPRINT" + )] #[serde_as(as = "serde_with::OneOrMany<_>")] pub fingerprint: Vec, @@ -174,8 +178,8 @@ pub struct Client { /// Fine for local development and between relays, but should be used in caution in production. #[serde(skip_serializing_if = "Option::is_none")] #[arg( - id = "tls-disable-verify", - long = "tls-disable-verify", + id = "client-tls-disable-verify", + long = "client-tls-disable-verify", env = "MOQ_CLIENT_TLS_DISABLE_VERIFY", default_missing_value = "true", num_args = 0..=1, @@ -183,6 +187,46 @@ pub struct Client { value_parser = clap::value_parser!(bool), )] pub disable_verify: Option, + + /// Deprecated `--tls-*` spellings, folded into the canonical fields above with + /// a warning. Private and hidden so they stay off the public surface; not a + /// TOML field (config files use the canonical names). + #[command(flatten)] + #[serde(skip)] + deprecated: Deprecated, +} + +/// Holds the deprecated bare `--tls-*` flag spellings (renamed to `--client-tls-*`). +/// Flattened into [`Client`] so they keep parsing; folded into the canonical +/// fields by [`Client::build`] with a deprecation warning. No env (the env names +/// were never renamed) and no TOML. +#[derive(Clone, Default, Debug, clap::Args)] +struct Deprecated { + #[arg(long = "tls-root", hide = true)] + root: Vec, + + #[arg( + long = "tls-system-roots", + hide = true, + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + value_parser = clap::value_parser!(bool), + )] + system_roots: Option, + + #[arg(long = "tls-fingerprint", hide = true)] + fingerprint: Vec, + + #[arg( + long = "tls-disable-verify", + hide = true, + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + value_parser = clap::value_parser!(bool), + )] + disable_verify: Option, } /// The resolved server-certificate verification policy. @@ -193,7 +237,7 @@ pub struct Client { /// are valid. #[derive(Clone)] pub(crate) enum Verification { - /// No verification at all. Insecure; only via `--tls-disable-verify`. + /// No verification at all. Insecure; only via `--client-tls-disable-verify`. Disabled, /// Pin the leaf certificate by SHA-256. The CA chain is not consulted, so @@ -206,32 +250,76 @@ pub(crate) enum Verification { } impl Client { + /// Log a warning for each deprecated `--tls-*` flag in use. Called once from + /// [`Self::verification`], which every backend runs, so a deprecated flag warns once. + pub(crate) fn warn_deprecated(&self) { + if !self.deprecated.root.is_empty() { + tracing::warn!("--tls-root is deprecated; use --client-tls-root"); + } + if self.deprecated.system_roots.is_some() { + tracing::warn!("--tls-system-roots is deprecated; use --client-tls-system-roots"); + } + if !self.deprecated.fingerprint.is_empty() { + tracing::warn!("--tls-fingerprint is deprecated; use --client-tls-fingerprint"); + } + if self.deprecated.disable_verify.is_some() { + tracing::warn!("--tls-disable-verify is deprecated; use --client-tls-disable-verify"); + } + } + + /// Roots from the canonical field plus the deprecated `--tls-root` spelling. + pub(crate) fn effective_root(&self) -> Vec { + let mut root = self.root.clone(); + root.extend(self.deprecated.root.iter().cloned()); + root + } + + /// Fingerprints from the canonical field plus the deprecated `--tls-fingerprint`. + pub(crate) fn effective_fingerprint(&self) -> Vec { + let mut fp = self.fingerprint.clone(); + fp.extend(self.deprecated.fingerprint.iter().cloned()); + fp + } + + /// `system_roots`, preferring the canonical flag over the deprecated alias. + pub(crate) fn effective_system_roots(&self) -> Option { + self.system_roots.or(self.deprecated.system_roots) + } + + /// `disable_verify`, preferring the canonical flag over the deprecated alias. + pub(crate) fn effective_disable_verify(&self) -> Option { + self.disable_verify.or(self.deprecated.disable_verify) + } + /// Resolve the verification policy from the configured flags. /// /// Precedence and rules (shared by all backends): - /// - `--tls-disable-verify` wins and disables verification. - /// - `--tls-fingerprint` pins the leaf and bypasses the CA chain; combining - /// it with `--tls-root` or `--tls-system-roots` is rejected rather than + /// - `--client-tls-disable-verify` wins and disables verification. + /// - `--client-tls-fingerprint` pins the leaf and bypasses the CA chain; combining + /// it with `--client-tls-root` or `--client-tls-system-roots` is rejected rather than /// silently ignoring one of them. /// - Otherwise, verify against the system roots (default) plus any custom /// roots. The system roots are dropped once a custom root is given unless - /// `--tls-system-roots` re-enables them. + /// `--client-tls-system-roots` re-enables them. pub(crate) fn verification(&self) -> Result { - if self.disable_verify.unwrap_or_default() { + self.warn_deprecated(); + + if self.effective_disable_verify().unwrap_or_default() { return Ok(Verification::Disabled); } let fingerprints = self.fingerprints()?; if !fingerprints.is_empty() { - if !self.root.is_empty() || self.system_roots == Some(true) { + if !self.effective_root().is_empty() || self.effective_system_roots() == Some(true) { return Err(Error::FingerprintWithRoots); } return Ok(Verification::Fingerprints(fingerprints)); } + let root = self.effective_root(); // Default to system roots only when no custom root is given, so passing a // root replaces them unless the system roots are explicitly re-enabled. - let system_roots = self.system_roots.unwrap_or(self.root.is_empty()); + let system_roots = self.effective_system_roots().unwrap_or(root.is_empty()); let mut roots = Vec::new(); if system_roots { @@ -241,7 +329,7 @@ impl Client { } roots.extend(native.certs); } - for root in &self.root { + for root in &root { let certs = read_certs(root)?; if certs.is_empty() { return Err(Error::EmptyRoots(root.clone())); @@ -262,17 +350,17 @@ impl Client { /// honored for a connection. /// /// Only when no stronger verification is configured: an explicit - /// `--tls-fingerprint` must never be weakened by an attacker-controlled + /// `--client-tls-fingerprint` must never be weakened by an attacker-controlled /// plaintext fetch, and there is nothing to bootstrap when verification is /// disabled. With CA roots (the default), `http://` is the deliberate /// per-connection way to pin a self-signed relay, so it is allowed. pub(crate) fn allows_http_bootstrap(&self) -> bool { - self.fingerprint.is_empty() && !self.disable_verify.unwrap_or_default() + self.effective_fingerprint().is_empty() && !self.effective_disable_verify().unwrap_or_default() } /// Parse the configured fingerprints into fixed-size SHA-256 digests. fn fingerprints(&self) -> Result> { - self.fingerprint + self.effective_fingerprint() .iter() .map(|fp| { let bytes = hex::decode(fp.trim()).map_err(Error::Fingerprint)?; diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 84addb446..8a5398355 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -166,40 +166,34 @@ impl axum::response::IntoResponse for AuthError { } } -/// TLS configuration for HTTP requests made by the auth client (JWK fetches -/// and public-API lookups). -/// -/// Mirrors [`moq_native::tls::Client`] so the auth client can be configured -/// independently of the cluster client. Defaults to system roots with no -/// client identity, which is what most external auth endpoints expect. +/// Deprecated `--auth-tls-*` overrides, kept for backwards compatibility. The +/// auth client otherwise reuses the cluster client's `--client-tls-*` config. +/// Hidden from `--help`; setting any field logs a deprecation warning. +#[doc(hidden)] #[serde_as] #[derive(Clone, Default, Debug, clap::Args, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] #[non_exhaustive] pub struct AuthTls { - /// PEM file(s) of root CAs. If empty, the platform's native roots are used. - /// In config files, accepts either a single string or a TOML array. #[serde(skip_serializing_if = "Vec::is_empty")] - #[arg(id = "auth-tls-root", long = "auth-tls-root", env = "MOQ_AUTH_TLS_ROOT")] + #[arg(id = "auth-tls-root", long = "auth-tls-root", env = "MOQ_AUTH_TLS_ROOT", hide = true)] #[serde_as(as = "OneOrMany<_>")] pub root: Vec, - /// PEM file containing the client certificate chain for mTLS. #[serde(skip_serializing_if = "Option::is_none")] - #[arg(id = "auth-tls-cert", long = "auth-tls-cert", env = "MOQ_AUTH_TLS_CERT")] + #[arg(id = "auth-tls-cert", long = "auth-tls-cert", env = "MOQ_AUTH_TLS_CERT", hide = true)] pub cert: Option, - /// PEM file containing the private key for mTLS. #[serde(skip_serializing_if = "Option::is_none")] - #[arg(id = "auth-tls-key", long = "auth-tls-key", env = "MOQ_AUTH_TLS_KEY")] + #[arg(id = "auth-tls-key", long = "auth-tls-key", env = "MOQ_AUTH_TLS_KEY", hide = true)] pub key: Option, - /// Danger: Disable TLS certificate verification on auth requests. #[serde(skip_serializing_if = "Option::is_none")] #[arg( id = "auth-tls-disable-verify", long = "auth-tls-disable-verify", env = "MOQ_AUTH_TLS_DISABLE_VERIFY", + hide = true, default_missing_value = "true", num_args = 0..=1, require_equals = true, @@ -209,6 +203,12 @@ pub struct AuthTls { } impl AuthTls { + /// True when any deprecated `--auth-tls-*` override is configured, in which + /// case it takes precedence over the shared `--client-tls-*` identity. + fn is_set(&self) -> bool { + !self.root.is_empty() || self.cert.is_some() || self.key.is_some() || self.disable_verify.is_some() + } + /// Convert into a [`moq_native::tls::Client`] so we can reuse its /// rustls-building logic. The fields map one-to-one. fn to_client_tls(&self) -> anyhow::Result { @@ -249,11 +249,18 @@ pub struct AuthConfig { #[arg(long = "auth-key-dir", env = "MOQ_AUTH_KEY_DIR")] pub key_dir: Option, - /// TLS configuration for outbound HTTP auth requests (JWK + public-API). + /// Deprecated `--auth-tls-*` overrides; see [`AuthTls`]. #[command(flatten)] #[serde(default)] pub tls: AuthTls, + /// Cluster client TLS injected by [`AuthConfig::init`] so outbound auth HTTP + /// (JWK + auth/public-API fetches) reuses the `--client-tls-*` identity. + /// Not a CLI or TOML field; the deprecated `--auth-tls-*` flags override it. + #[arg(skip)] + #[serde(skip)] + client_tls: Option, + /// Public (unauthenticated) access configuration. /// /// CLI: `--auth-public ` sets both subscribe and publish for the prefix. @@ -520,7 +527,12 @@ impl PublicAccess { impl AuthConfig { /// Initializes an [`Auth`] instance from this configuration. - pub async fn init(self) -> anyhow::Result { + /// + /// `client_tls` is the cluster client TLS (`--client-tls-*`); the auth client + /// reuses it for outbound HTTP unless the deprecated `--auth-tls-*` flags are + /// set. + pub async fn init(mut self, client_tls: &moq_native::tls::Client) -> anyhow::Result { + self.client_tls = Some(client_tls.clone()); Auth::new(self).await } @@ -692,7 +704,20 @@ impl Auth { "--auth-api cannot be combined with --auth-key/--auth-key-dir/--auth-public/--auth-public-api" ); - let tls = config.tls.to_client_tls()?.build()?; + // Outbound auth HTTP (JWK + auth/public-API fetches) reuses the cluster + // client's --client-tls-* identity. The deprecated --auth-tls-* flags + // still override it when set. + let tls_config = if config.tls.is_set() { + tracing::warn!( + "the --auth-tls-* flags are deprecated and will be removed; the auth client now \ + reuses the cluster client TLS (--client-tls-root, --client-tls-cert, --client-tls-key). \ + Drop --auth-tls-* and configure those instead." + ); + config.tls.to_client_tls()? + } else { + config.client_tls.clone().unwrap_or_default() + }; + let tls = tls_config.build()?; let source = if let Some(key) = config.key { let source = if let Ok(url) = Url::parse(&key) { @@ -2642,6 +2667,27 @@ api = "https://api.example.com/access" .await?; assert_eq!(verified.root, "room/1".as_path()); + // New path: the identity is supplied via the shared --client-tls-* config + // (injected through AuthConfig::init) instead of the deprecated + // --auth-tls-* flags. The server accepts it the same way. + let mut client_tls = moq_native::tls::Client::default(); + client_tls.root = vec![fx.ca_pem_path.clone()]; + client_tls.cert = Some(fx.client_cert_path.clone()); + client_tls.key = Some(fx.client_key_path.clone()); + let auth_via_client_tls = AuthConfig { + key_dir: Some(format!("{}/keys/", fx.base_url)), + ..Default::default() + } + .init(&client_tls) + .await?; + let verified = auth_via_client_tls + .verify(&AuthParams { + path: "/room/1".into(), + jwt: Some(token.clone()), + }) + .await?; + assert_eq!(verified.root, "room/1".as_path()); + // Without identity: the server should reject the TLS handshake → ApiUnavailable. let auth_no_identity = Auth::new(AuthConfig { key_dir: Some(format!("{}/keys/", fx.base_url)), diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index 3fd2c4e53..bb8914e7d 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -263,7 +263,7 @@ auth_api = "https://api.moq.dev/cluster/auth" } /// Same clap+TOML clobber guard for `client.system_roots`. It's typed as - /// `Option` so an absent `--tls-system-roots` CLI flag must not wipe a + /// `Option` so an absent `--client-tls-system-roots` CLI flag must not wipe a /// TOML-configured value during the `update_from` re-parse. A bare `bool` /// would reset it to `false`, silently dropping the system roots for a /// cluster client that opted into trusting both system and custom roots. diff --git a/rs/moq-relay/src/main.rs b/rs/moq-relay/src/main.rs index 8b9d27b36..8664cc6fc 100644 --- a/rs/moq-relay/src/main.rs +++ b/rs/moq-relay/src/main.rs @@ -46,7 +46,7 @@ async fn main() -> anyhow::Result<()> { let auth = if config.auth.is_empty() { Auth::default() } else { - config.auth.init().await? + config.auth.init(&config.client.tls).await? }; let cluster = Cluster::new(config.cluster)? diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index c9a8511ce..32daa8f59 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -46,7 +46,10 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { let public = PublicConfig::Simple(vec![String::new()]); let mut auth_config = AuthConfig::default(); auth_config.public = Some(public); - let auth = auth_config.init().await.expect("auth init"); + let auth = auth_config + .init(&moq_native::tls::Client::default()) + .await + .expect("auth init"); let cluster = Cluster::new(ClusterConfig::default()).expect("cluster init"); From 94629e5f1751ebd38206f69f0bcf97f5ae317ad0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 24 Jun 2026 13:38:19 -0700 Subject: [PATCH 06/34] refactor(net): encapsulate Group.state behind non-blocking read methods (#1908) Co-authored-by: Claude Opus 4.8 --- js/json/src/consumer.ts | 25 ++++++++++-- js/json/src/json.test.ts | 13 +++++++ js/net/src/group.test.ts | 77 +++++++++++++++++++++++++++++++++++++ js/net/src/group.ts | 82 ++++++++++++++++++++++++++++------------ js/net/src/track.test.ts | 20 ++++++++++ js/net/src/track.ts | 22 ++++++----- 6 files changed, 201 insertions(+), 38 deletions(-) create mode 100644 js/net/src/group.test.ts diff --git a/js/json/src/consumer.ts b/js/json/src/consumer.ts index 721edf974..a142f22e1 100644 --- a/js/json/src/consumer.ts +++ b/js/json/src/consumer.ts @@ -7,8 +7,9 @@ import type { Config } from "./producer.ts"; /** * Consumes a JSON value from a track, reconstructing it from snapshots and deltas. * - * Reads each group's snapshot (frame 0) and applies the following frames as merge patches, - * yielding the reconstructed value after each one. + * Reads each group's snapshot (frame 0) and applies the following frames as merge patches. A live + * consumer yields each update as it arrives; a consumer that has fallen behind (or just joined) + * collapses the buffered backlog and yields only the latest value. See {@link next}. */ export class Consumer { #track: Moq.Track; @@ -28,7 +29,15 @@ export class Consumer { this.#decompress = config.compression ?? false; } - /** Get the next reconstructed value, or `undefined` once the track ends. */ + /** + * Get the next reconstructed value, or `undefined` once the track ends. + * + * Applies every frame already buffered in the group but yields only the latest reconstructed + * value: the intermediate reconstructions are stale, so a late joiner (or any consumer that has + * fallen behind) catches up to the head in one step instead of replaying every superseded state. + * Frames are still decoded in order (the DEFLATE window and merge patches are sequential); only + * the per-frame yield is skipped. + */ async next(): Promise { for (;;) { if (!this.#group) { @@ -41,6 +50,16 @@ export class Consumer { this.#decoder = undefined; } + // Drain every frame already buffered, keeping only the latest reconstructed value. + let value: T | undefined; + let advanced = false; + for (let frame = this.#group.tryReadFrame(); frame !== undefined; frame = this.#group.tryReadFrame()) { + value = this.#apply(frame); + advanced = true; + } + if (advanced) return value; + + // Nothing buffered: block for the next frame (or the group's end). const frame = await this.#group.readFrame(); if (frame === undefined) { // The group is exhausted; advance to the next one. diff --git a/js/json/src/json.test.ts b/js/json/src/json.test.ts index b3b79c7ea..d72016772 100644 --- a/js/json/src/json.test.ts +++ b/js/json/src/json.test.ts @@ -186,6 +186,19 @@ test("array change is a wholesale delta", async () => { expect(await structure(track)).toEqual([2]); }); +test("late joiner collapses a buffered backlog to the latest value", async () => { + const track = new Track("test"); + const producer = new Producer(track, { deltaRatio: 100 }); + for (let n = 0; n <= 20; n++) { + producer.update({ n }); + } + producer.finish(); + + // A whole group's worth of snapshot + deltas is buffered before the consumer reads, so it applies + // them all but yields only the latest value once, not every superseded state. + expect(await drain(track)).toEqual([{ n: 20 }]); +}); + test("frame cap rolls snapshot", async () => { const track = new Track("test"); const producer = new Producer(track, { deltaRatio: 1_000_000 }); diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts new file mode 100644 index 000000000..21f0f216c --- /dev/null +++ b/js/net/src/group.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { Group } from "./group.ts"; + +const dec = new TextDecoder(); + +test("tryReadFrame drains buffered frames then returns undefined", () => { + const group = new Group(0); + group.writeString("a"); + group.writeString("b"); + + expect(dec.decode(group.tryReadFrame())).toBe("a"); + expect(dec.decode(group.tryReadFrame())).toBe("b"); + // Nothing buffered: undefined, and the group is not closed so this is not end-of-group. + expect(group.tryReadFrame()).toBeUndefined(); +}); + +test("tryReadFrameSequence reports per-frame sequence numbers", () => { + const group = new Group(7); + group.writeString("a"); + group.writeString("b"); + + expect(group.tryReadFrameSequence()).toEqual({ sequence: 0, data: new TextEncoder().encode("a") }); + expect(group.tryReadFrameSequence()).toEqual({ sequence: 1, data: new TextEncoder().encode("b") }); + expect(group.tryReadFrameSequence()).toBeUndefined(); +}); + +test("done distinguishes a finished group from one that is merely empty", () => { + const group = new Group(0); + // Open and empty: not done (more frames may arrive), and tryReadFrame is undefined. + expect(group.tryReadFrame()).toBeUndefined(); + expect(group.done).toBe(false); + + group.writeString("a"); + // Buffered but closed: still not done until the frame is drained. + group.close(); + expect(group.done).toBe(false); + + group.tryReadFrame(); + // Drained and closed: now done. + expect(group.tryReadFrame()).toBeUndefined(); + expect(group.done).toBe(true); +}); + +test("readable resolves once a frame is buffered", async () => { + const group = new Group(0); + // No frame yet: readable() must stay pending for an empty, open group. + const readable = group.readable(); + let settled = false; + void readable.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + // Writing makes it resolve. + group.writeString("hi"); + await readable; // must not hang + expect(dec.decode(group.tryReadFrame())).toBe("hi"); +}); + +test("readable resolves once the group closes, even with nothing buffered", async () => { + const group = new Group(0); + const readable = group.readable(); + group.close(); + await readable; // resolves on close so callers don't wait forever + expect(group.tryReadFrame()).toBeUndefined(); +}); + +test("buffered frames are still readable after the group closes", async () => { + const group = new Group(0); + group.writeString("a"); + group.close(); + + // Closing doesn't discard buffered frames; the blocking reader drains them before ending. + expect(await group.readString()).toBe("a"); + expect(await group.readFrame()).toBeUndefined(); +}); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index cac899a43..c39ff09a4 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -1,7 +1,7 @@ import { Signal } from "@moq/signals"; /** Reactive backing state for a {@link Group}: buffered frames, a closed flag, and the running frame count. */ -export class GroupState { +class GroupState { frames = new Signal([]); closed = new Signal(false); total = new Signal(0); // The total number of frames in the group thus far @@ -12,8 +12,9 @@ export class Group { /** Sequence number of this group within its track. */ readonly sequence: number; - /** Reactive backing state. */ - state = new GroupState(); + // Reactive backing state, deliberately private: read through the read* methods so callers can't + // poke the signals directly (and so the internal representation can change). + #state = new GroupState(); /** Resolves with the abort error (or undefined) once closed. */ readonly closed: Promise; @@ -23,7 +24,7 @@ export class Group { // Cache the closed promise to avoid recreating it every time. this.closed = new Promise((resolve) => { - const dispose = this.state.closed.subscribe((closed) => { + const dispose = this.#state.closed.subscribe((closed) => { if (!closed) return; resolve(closed instanceof Error ? closed : undefined); dispose(); @@ -36,13 +37,13 @@ export class Group { * @param frame - The frame to write */ writeFrame(frame: Uint8Array) { - if (this.state.closed.peek()) throw new Error("group is closed"); + if (this.#state.closed.peek()) throw new Error("group is closed"); - this.state.frames.mutate((frames) => { + this.#state.frames.mutate((frames) => { frames.push(frame); }); - this.state.total.update((total) => total + 1); + this.#state.total.update((total) => total + 1); } /** Write a string as a single UTF-8 encoded frame. */ @@ -60,36 +61,67 @@ export class Group { this.writeFrame(new Uint8Array([bool ? 1 : 0])); } + /** True once no further frames can be read: the group has closed and every buffered frame is read. */ + get done(): boolean { + return this.#state.frames.peek().length === 0 && this.#state.closed.peek() !== false; + } + /** - * Reads the next frame from the group. - * @returns A promise that resolves to the next frame or undefined + * Reads the next already-buffered frame without blocking. + * + * Returns `undefined` when nothing is buffered right now. That is *not* by itself end-of-group: + * check {@link done} to tell "no frame buffered yet" (more may arrive) from "the group finished". + * Drain a backlog by looping until this returns `undefined`, then branch on {@link done}: if not + * done, {@link readable} resolves when the next frame arrives. */ - async readFrame(): Promise { - for (;;) { - const frames = this.state.frames.peek(); - const frame = frames.shift(); - if (frame) return frame; + tryReadFrame(): Uint8Array | undefined { + return this.tryReadFrameSequence()?.data; + } - const closed = this.state.closed.peek(); - if (closed instanceof Error) throw closed; - if (closed) return; + /** Like {@link tryReadFrame} but also reports the frame's sequence number within the group. */ + tryReadFrameSequence(): { sequence: number; data: Uint8Array } | undefined { + const frames = this.#state.frames.peek(); + const data = frames.shift(); + if (data === undefined) return undefined; + return { sequence: this.#state.total.peek() - frames.length - 1, data }; + } - await Signal.race(this.state.frames, this.state.closed); + /** + * Resolves once {@link readFrame} would not block: a frame is buffered, or the group has closed. + * Always settles (never hangs), so on a finished group it resolves immediately; pair it with + * {@link done} to avoid re-waiting on a group that has nothing left. + * + * Lets a caller fold "this group has a frame" into a larger wait (e.g. racing it against a new + * group arriving) without touching the group's internal signals. + */ + async readable(): Promise { + for (;;) { + if (this.#state.frames.peek().length > 0) return; + if (this.#state.closed.peek()) return; + await Signal.race(this.#state.frames, this.#state.closed); } } + /** + * Reads the next frame from the group. + * @returns A promise that resolves to the next frame or undefined + */ + async readFrame(): Promise { + return (await this.readFrameSequence())?.data; + } + /** Reads the next frame along with its sequence number within the group. */ async readFrameSequence(): Promise<{ sequence: number; data: Uint8Array } | undefined> { for (;;) { - const frames = this.state.frames.peek(); - const frame = frames.shift(); - if (frame) return { sequence: this.state.total.peek() - frames.length - 1, data: frame }; + const next = this.tryReadFrameSequence(); + if (next) return next; - const closed = this.state.closed.peek(); + // Drain buffered frames before observing the close, so a closed group still yields them. + const closed = this.#state.closed.peek(); if (closed instanceof Error) throw closed; - if (closed) return; + if (closed) return undefined; - await Signal.race(this.state.frames, this.state.closed); + await this.readable(); } } @@ -113,6 +145,6 @@ export class Group { /** Closes the group, optionally with an error to abort readers. */ close(abort?: Error) { - this.state.closed.set(abort ?? true); + this.#state.closed.set(abort ?? true); } } diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index bec5d8dfb..c8b70be5f 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -49,3 +49,23 @@ test("nextGroupOrdered returns undefined when track closes", async () => { track.close(); expect(await track.nextGroupOrdered()).toBeUndefined(); }); + +test("readFrame does not livelock when a sole group finishes before the next arrives", async () => { + const track = new Track("test"); + + // A group is appended then finished empty while the track stays open. A finished group's + // readable() resolves immediately, so the reader must not busy-wait on it (which would starve the + // macrotask queue and never observe the next group). + const g0 = track.appendGroup(); + g0.close(); + + // The next group arrives via a macrotask; if the reader livelocks on microtasks it never runs. + setTimeout(() => { + const g1 = track.appendGroup(); + g1.writeString("hello"); + g1.close(); + track.close(); + }, 10); + + expect(await track.readString()).toBe("hello"); +}, 2000); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index afa557eac..9a10451b4 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -162,11 +162,9 @@ export class Track { // Discard old groups. while (groups.length > 1) { - const frames = groups[0].state.frames.peek(); - const next = frames.shift(); + const next = groups[0].tryReadFrameSequence(); if (next) { - const frame = groups[0].state.total.peek() - frames.length - 1; - return { group: groups[0].sequence, frame, data: next }; + return { group: groups[0].sequence, frame: next.sequence, data: next.data }; } // Skip this old group @@ -185,11 +183,9 @@ export class Track { // If there's a group, wait for a frame. const group = groups[0]; - const frames = group.state.frames.peek(); - const next = frames.shift(); + const next = group.tryReadFrameSequence(); if (next) { - const frame = group.state.total.peek() - frames.length - 1; - return { group: group.sequence, frame, data: next }; + return { group: group.sequence, frame: next.sequence, data: next.data }; } // If the track is closed, return undefined. @@ -197,8 +193,14 @@ export class Track { if (closed instanceof Error) throw closed; if (closed) return undefined; - // NOTE: We don't care if the latest group was closed or not. - await Signal.race(this.state.groups, this.state.closed, group.state.frames); + // Wake on a new group or the track closing. Only fold in the current group's readability + // while it can still deliver frames: a finished group's readable() resolves immediately, so + // racing it would busy-loop until a newer group arrives instead of waiting. + if (group.done) { + await Signal.race(this.state.groups, this.state.closed); + } else { + await Promise.race([Signal.race(this.state.groups, this.state.closed), group.readable()]); + } } } From c255e85dd04348bca4deec5805097ef114241805 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 24 Jun 2026 14:53:46 -0700 Subject: [PATCH 07/34] refactor(moq-json): gate group rolls on already-written deltas (#1909) Co-authored-by: Claude Opus 4.8 --- js/json/src/compression.test.ts | 32 ++++++++ js/json/src/json.test.ts | 25 +++--- js/json/src/producer.ts | 68 +++++++++------- rs/moq-json/src/lib.rs | 133 ++++++++++++++++++-------------- 4 files changed, 161 insertions(+), 97 deletions(-) diff --git a/js/json/src/compression.test.ts b/js/json/src/compression.test.ts index e7fd70939..895e8b241 100644 --- a/js/json/src/compression.test.ts +++ b/js/json/src/compression.test.ts @@ -25,6 +25,17 @@ async function firstFrame(track: Track): Promise { return frame; } +// Count the groups a (finished) track published, draining each so the reads terminate. +async function groupCount(track: Track): Promise { + let groups = 0; + for (;;) { + const group = await track.nextGroupOrdered(); + if (!group) return groups; + groups++; + while ((await group.readFrame()) !== undefined) {} + } +} + test("compressed snapshot per group round-trips", async () => { const track = new Track("test"); const producer = new Producer(track, { deltaRatio: 0, compression: true }); @@ -114,3 +125,24 @@ test("compression shrinks a repetitive frame", async () => { const compressedLen = (await firstFrame(compressed)).length; expect(compressedLen).toBeLessThan(plainLen); }); + +test("compressed deltas roll on the compressed budget", async () => { + // With compression the budget is measured on compressed frame sizes: #snapshotLen and #deltaBytes + // are the written slice lengths, not the raw JSON. A tight ratio over many distinct updates must + // roll more than one group, and a late joiner must still rebuild the final value across the + // compressed group boundary. Guards against the budget regressing to raw lengths. Two identical + // producers (deterministic output) keep the group-count and reconstruction reads independent. + const fill = (track: Track) => { + const producer = new Producer(track, { deltaRatio: 2, compression: true }); + for (let n = 0; n <= 40; n++) producer.update({ n }); + producer.finish(); + }; + + const layout = new Track("layout"); + fill(layout); + expect(await groupCount(layout)).toBeGreaterThan(1); + + const reconstruct = new Track("reconstruct"); + fill(reconstruct); + expect((await drainCompressed(reconstruct)).at(-1)).toEqual({ n: 40 }); +}); diff --git a/js/json/src/json.test.ts b/js/json/src/json.test.ts index d72016772..f72641af6 100644 --- a/js/json/src/json.test.ts +++ b/js/json/src/json.test.ts @@ -151,28 +151,31 @@ test("mutate removes a section", async () => { test("tight ratio rolls snapshots", async () => { const track = new Track("test"); - // A ratio of 1 admits deltas only up to the snapshot size: with equal 7-byte frames that is a - // single delta per group, so it rolls every other update. + // A ratio of 1 budgets deltas up to one snapshot (equal 7-byte frames => 7 bytes). The gate checks + // the deltas already written, so the delta that tips the group over budget still lands (a one-frame + // overshoot): group 0 takes two deltas (14 bytes) before the fourth update rolls group 1. const producer = new Producer(track, { deltaRatio: 1 }); producer.update({ a: 1 }); // snapshot, group 0 - producer.update({ a: 2 }); // delta, group 0 - producer.update({ a: 3 }); // exceeds budget, rolls group 1 - producer.update({ a: 4 }); // delta, group 1 + producer.update({ a: 2 }); // delta, group 0 (deltas = 7) + producer.update({ a: 3 }); // delta, group 0 (deltas = 14, now over budget) + producer.update({ a: 4 }); // budget already exceeded, rolls group 1 producer.finish(); - expect(await structure(track)).toEqual([2, 2]); + expect(await structure(track)).toEqual([3, 1]); }); test("deltas stay within ratio times snapshot", async () => { const track = new Track("test"); - // The budget covers only the deltas, not the snapshot frame, measured against the current - // snapshot size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so a - // ratio of 8 admits 8 deltas (8x the snapshot) on top of the snapshot before the 9th rolls. + // The budget covers only the deltas, not the snapshot frame, measured against the group's snapshot + // size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so a ratio of 8 + // budgets 56 bytes of deltas. The gate checks the deltas already written, so the group keeps filling + // until they first exceed 56 (nine deltas = 63 bytes) and the next update rolls (a one-frame + // overshoot past the 56-byte budget). const producer = new Producer(track, { deltaRatio: 8 }); - for (let n = 0; n <= 9; n++) producer.update({ n }); + for (let n = 0; n <= 10; n++) producer.update({ n }); producer.finish(); - expect(await structure(track)).toEqual([9, 1]); + expect(await structure(track)).toEqual([10, 1]); }); test("array change is a wholesale delta", async () => { diff --git a/js/json/src/producer.ts b/js/json/src/producer.ts index afea510a2..62637c052 100644 --- a/js/json/src/producer.ts +++ b/js/json/src/producer.ts @@ -17,10 +17,13 @@ export interface Config { // // `0` disables deltas: every change is published as a new snapshot group. // - // A positive number enables deltas: a delta is appended to the current group as long as the - // accumulated deltas (excluding the snapshot frame) stay within `deltaRatio` times the size of a - // fresh snapshot; otherwise a new snapshot group is started. So `1` allows deltas totalling up to - // one snapshot before rolling. + // A positive number enables deltas: a new snapshot group is started once the deltas already written + // to the current group (excluding the snapshot frame) exceed `deltaRatio` times the snapshot size. + // The pending delta is excluded from that check, so the one that first crosses the budget still + // lands before the group rolls. So `1` allows roughly one snapshot's worth of deltas before rolling. + // + // When {@link compression} is on, both sides of the comparison are measured on the compressed frame + // sizes (the real wire cost). // // Defaults to `8` when unset. deltaRatio?: number; @@ -56,11 +59,13 @@ export class Producer { #track?: Moq.Track; #group?: Moq.Group; #last?: unknown; - // Bytes of deltas accumulated in the current group, excluding the snapshot frame. Always raw - // (uncompressed) sizes, even when compressing: the delta-vs-snapshot decision measures raw bytes, - // so a compressed producer rolls groups on raw sizes (still valid on the wire, just a touch sooner - // than the Rust producer, which measures compressed sizes). + // Bytes of deltas already written to the current group, excluding the snapshot frame. Compressed + // frame sizes when compressing, raw otherwise, matching {@link #snapshotLen} so the budget check is + // like-for-like (and identical to the Rust producer). #deltaBytes = 0; + // Size of the current group's snapshot frame, the reference the delta budget is measured against. + // Compressed when compressing, raw otherwise. + #snapshotLen = 0; #groupFrames = 0; // Group-scoped `deflate-raw` compression. `#encoder` is the current group's stream, swapped for a @@ -124,10 +129,9 @@ export class Producer { if (this.#last !== undefined && deepEqual(this.#last, json)) return; const snapshot = new TextEncoder().encode(text); - const delta = this.#delta(json, snapshot.length); + const delta = this.#delta(json); if (delta && this.#group) { - this.#writeDelta(this.#group, delta); - this.#deltaBytes += delta.length; + this.#deltaBytes += this.#writeDelta(this.#group, delta); this.#groupFrames += 1; } else { this.#snapshot(this.#track, snapshot); @@ -211,21 +215,24 @@ export class Producer { return this.#config.deltaRatio ?? DEFAULT_DELTA_RATIO; } - #delta(json: unknown, snapshotLen: number): Uint8Array | undefined { + // Build a delta frame, or `undefined` to signal that a fresh snapshot should be published. + // + // The budget gate runs first, against the deltas already written, so rolling a new group costs no + // merge-patch work. Since the gate excludes the frame about to be written, the delta that tips the + // group past `ratio * snapshot` still lands: a group overshoots the budget by at most one delta. + #delta(json: unknown): Uint8Array | undefined { const ratio = this.#deltaRatio; if (ratio === 0) return undefined; if (this.#last === undefined) return undefined; if (!this.#group || this.#groupFrames >= MAX_DELTA_FRAMES) return undefined; + // Gate on the deltas accumulated so far (snapshot frame excluded), before computing the patch. + if (this.#deltaBytes > ratio * this.#snapshotLen) return undefined; + const result = diff(this.#last, json); if (result.forcedSnapshot) return undefined; - const delta = new TextEncoder().encode(JSON.stringify(result.patch)); - - // Roll a snapshot once the deltas would outgrow the budget (snapshot frame excluded). - if (this.#deltaBytes + delta.length > ratio * snapshotLen) return undefined; - - return delta; + return new TextEncoder().encode(JSON.stringify(result.patch)); } #snapshot(track: Moq.Track, snapshot: Uint8Array): void { @@ -233,7 +240,7 @@ export class Producer { this.#group?.close(); const group = track.appendGroup(); - this.#writeSnapshot(group, snapshot); + this.#snapshotLen = this.#writeSnapshot(group, snapshot); this.#deltaBytes = 0; this.#groupFrames = 1; @@ -247,24 +254,29 @@ export class Producer { } } - // Write a group's snapshot (frame 0). On the compressed path this opens a fresh per-group encoder - // (cold window), so the snapshot and its deltas share one DEFLATE stream. - #writeSnapshot(group: Moq.Group, frame: Uint8Array): void { + // Write a group's snapshot (frame 0), returning the bytes written. On the compressed path this opens + // a fresh per-group encoder (cold window), so the snapshot and its deltas share one DEFLATE stream. + #writeSnapshot(group: Moq.Group, frame: Uint8Array): number { if (!this.#compress) { group.writeFrame(frame); - return; + return frame.length; } this.#encoder = new Encoder(); - group.writeFrame(this.#encoder.frame(frame)); + const slice = this.#encoder.frame(frame); + group.writeFrame(slice); + return slice.length; } - // Write a delta frame, compressed against the current group's encoder when compressing. - #writeDelta(group: Moq.Group, frame: Uint8Array): void { + // Write a delta frame, compressed against the current group's encoder when compressing. Returns the + // bytes written. + #writeDelta(group: Moq.Group, frame: Uint8Array): number { if (!this.#compress) { group.writeFrame(frame); - return; + return frame.length; } if (!this.#encoder) throw new Error("compressed delta requires an open group"); - group.writeFrame(this.#encoder.frame(frame)); + const slice = this.#encoder.frame(frame); + group.writeFrame(slice); + return slice.length; } } diff --git a/rs/moq-json/src/lib.rs b/rs/moq-json/src/lib.rs index cec1f9b0f..fffb0d65d 100644 --- a/rs/moq-json/src/lib.rs +++ b/rs/moq-json/src/lib.rs @@ -69,10 +69,11 @@ pub struct ProducerConfig { /// /// A ratio of `0` disables deltas: every change is published as a new snapshot group. /// - /// A positive ratio enables deltas. A delta is appended to the current group as long as the - /// accumulated deltas (excluding the snapshot frame) stay within `ratio` times the size of a - /// snapshot; otherwise a new snapshot group is started. So `1` allows deltas totalling up - /// to one snapshot before rolling, and a larger ratio tolerates more deltas per snapshot. + /// A positive ratio enables deltas. A new snapshot group is started once the deltas *already + /// written* to the current group (excluding the snapshot frame) exceed `ratio` times the snapshot + /// size. The pending delta is excluded from that check, so the one that first crosses the budget + /// still lands before the group rolls. So `1` allows roughly one snapshot's worth of deltas before + /// rolling, and a larger ratio tolerates more. /// /// When [`compression`](Self::compression) is on, both sides of the comparison are measured on /// the *compressed* frame sizes (the real wire cost). @@ -263,7 +264,7 @@ impl Inner { return Ok(()); } - match self.delta(&json, snapshot.len())? { + match self.delta(&json)? { Some(slice) => { let group = self.group.as_mut().expect("delta requires an open group"); let len = slice.len() as u64; @@ -278,11 +279,14 @@ impl Inner { Ok(()) } - /// Serialize (and, when compressing, compress) a delta if deltas are enabled and appending one - /// keeps the group within budget; otherwise `None`, signalling that a fresh snapshot should be - /// published instead. Returns the frame slice ready to write. - fn delta(&mut self, value: &Value, snapshot_len: usize) -> Result> { - let ratio = self.config.delta_ratio; + /// Build a delta frame for `value`, or `None` to signal that a fresh snapshot should be published. + /// + /// The budget gate runs first, against the deltas *already written*, so rolling a new group costs + /// no merge-patch or compression work. Because the gate doesn't include the frame about to be + /// written, the delta that tips the group past `ratio * snapshot` still lands: a group overshoots + /// the budget by at most one delta before rolling. + fn delta(&mut self, value: &Value) -> Result> { + let ratio = self.config.delta_ratio as u64; if ratio == 0 { return Ok(None); } @@ -290,39 +294,28 @@ impl Inner { return Ok(None); } - let patch = { - let Some(last) = &self.last else { - return Ok(None); - }; - let diff = diff(last, value); - if diff.forced_snapshot { - return Ok(None); - } - serde_json::to_vec(&diff.patch)? - }; + // Gate on the deltas accumulated so far, measured against the group's snapshot frame. Both are + // compressed sizes when compressing and raw otherwise, so the comparison is always like-for-like. + // Cheap: no diff, no merge patch, no compression yet. + if self.delta_bytes > ratio * self.snapshot_len { + return Ok(None); + } - match self.encoder.as_mut() { - // Compressed: measure the delta's *compressed* slice size (the real wire cost) against the - // group's anchoring snapshot, also compressed. Encoding advances the per-group window; if - // the delta doesn't fit we roll a new group with a fresh encoder, discarding this slice - // (the abandoned window has no effect on the new group). - Some(encoder) => { - let slice = encoder.frame(&patch); - let projected = self.delta_bytes + slice.len() as u64; - if projected > ratio as u64 * self.snapshot_len { - return Ok(None); - } - Ok(Some(slice)) - } - // Uncompressed: raw delta bytes against a fresh snapshot of the current value. - None => { - let projected = self.delta_bytes + patch.len() as u64; - if projected > ratio as u64 * snapshot_len as u64 { - return Ok(None); - } - Ok(Some(Bytes::from(patch))) - } + let Some(last) = &self.last else { + return Ok(None); + }; + let diff = diff(last, value); + if diff.forced_snapshot { + return Ok(None); } + let patch = serde_json::to_vec(&diff.patch)?; + + // Compress into the per-group window only now, for a frame we are committed to writing. + let slice = match self.encoder.as_mut() { + Some(encoder) => encoder.frame(&patch), + None => Bytes::from(patch), + }; + Ok(Some(slice)) } /// Start a new group with a full snapshot as its first frame. @@ -652,15 +645,16 @@ mod test { #[test] fn tight_ratio_rolls_snapshots() { - // A ratio of 1 admits deltas only up to the snapshot size: with equal 7-byte frames that is a - // single delta per group, so it rolls every other update. (Still distinct from 0, which would - // disable deltas entirely and never produce the group-0 delta below.) + // A ratio of 1 budgets deltas up to one snapshot (equal 7-byte frames => 7 bytes). The gate + // checks the deltas already written, so the delta that tips the group over budget still lands + // (a one-frame overshoot): group 0 takes two deltas (14 bytes) before the fourth update rolls + // group 1. (Still distinct from 0, which disables deltas entirely.) let config = cfg(1); let (mut producer, track) = producer(config); producer.update(&json!({ "a": 1 })).unwrap(); // snapshot, group 0 - producer.update(&json!({ "a": 2 })).unwrap(); // delta, group 0 - producer.update(&json!({ "a": 3 })).unwrap(); // exceeds budget, rolls group 1 - producer.update(&json!({ "a": 4 })).unwrap(); // delta, group 1 + producer.update(&json!({ "a": 2 })).unwrap(); // delta, group 0 (deltas = 7) + producer.update(&json!({ "a": 3 })).unwrap(); // delta, group 0 (deltas = 14, now over budget) + producer.update(&json!({ "a": 4 })).unwrap(); // budget already exceeded, rolls group 1 producer.finish().unwrap(); assert_eq!(track.latest(), Some(1)); @@ -668,20 +662,21 @@ mod test { #[test] fn deltas_stay_within_ratio_times_snapshot() { - // The budget covers only the deltas, not the snapshot frame, measured against the current - // snapshot size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so a - // `ratio = 8` admits 8 deltas (8x the snapshot, the inclusive limit) on top of the snapshot - // before the 9th delta rolls. + // The budget covers only the deltas, not the snapshot frame, measured against the group's + // snapshot size. Single-digit values keep every frame at a constant 7 bytes (`{"n":N}`), so + // `ratio = 8` budgets 56 bytes of deltas. The gate checks the deltas already written, so the + // group keeps filling until the accumulated deltas first exceed 56 (nine deltas = 63 bytes) and + // the next update rolls (a one-frame overshoot past the 56-byte budget). let config = cfg(8); let (mut producer, track) = producer(config); - for n in 0..=9 { + for n in 0..=10 { producer.update(&json!({ "n": n })).unwrap(); } producer.finish().unwrap(); - // Group 0 carries the snapshot plus 8 deltas (9 frames); the 9th delta opens group 1. + // Group 0 carries the snapshot plus 9 deltas (10 frames); the 10th delta opens group 1. assert_eq!(track.latest(), Some(1)); - assert_eq!(drain(track).last().unwrap(), &json!({ "n": 9 })); + assert_eq!(drain(track).last().unwrap(), &json!({ "n": 10 })); } #[test] @@ -768,7 +763,8 @@ mod test { #[test] fn newer_group_supersedes_in_progress_reconstruction() { - // A tight ratio lets one delta fit, then forces the next update into a new snapshot group. + // A tight ratio fills group 0 with a couple of deltas, then forces a later update into a new + // snapshot group (the gate overshoots the budget by one delta before rolling). let config = cfg(1); let (mut producer, track) = producer(config); let observer = producer.consume(); @@ -781,8 +777,9 @@ mod test { other => panic!("expected first value, got {other:?}"), } - producer.update(&json!({ "a": 2 })).unwrap(); // delta in group 0 - producer.update(&json!({ "a": 3 })).unwrap(); // exceeds budget, rolls group 1 + producer.update(&json!({ "a": 2 })).unwrap(); // delta in group 0 (deltas = 7) + producer.update(&json!({ "a": 3 })).unwrap(); // delta in group 0 (deltas = 14, now over budget) + producer.update(&json!({ "a": 4 })).unwrap(); // budget already exceeded, rolls group 1 producer.finish().unwrap(); assert_eq!(observer.latest(), Some(1)); @@ -791,7 +788,7 @@ mod test { while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { last = Some(value); } - assert_eq!(last.unwrap(), json!({ "a": 3 })); + assert_eq!(last.unwrap(), json!({ "a": 4 })); } #[test] @@ -927,6 +924,26 @@ mod test { assert_eq!(values.last().unwrap(), &json!({ "a": 5, "b": 2 })); } + #[test] + fn compressed_deltas_roll_on_compressed_budget() { + // With compression the budget is measured on compressed frame sizes: `snapshot_len` and + // `delta_bytes` are the compressed slice lengths, not the raw JSON. A tight ratio over many + // distinct updates must therefore roll at least one group, and a late joiner must still rebuild + // the final value across the compressed group boundary (per-group decoder reset). Guards against + // the budget regressing to raw lengths. + let (mut producer, track) = producer(cfg_deflate(2)); + for n in 0..=40 { + producer.update(&json!({ "n": n })).unwrap(); + } + producer.finish().unwrap(); + + assert!( + track.latest().unwrap() > 0, + "a tight ratio should roll at least one compressed group" + ); + assert_eq!(drain_with(deflate_consumer(track)).last().unwrap(), &json!({ "n": 40 })); + } + #[test] fn compressed_cloned_consumer_reconstructs_mid_group() { // A clone taken mid-group has no decoder window; it must rebuild from the retained slices. From 5bfe7bf8fc368f3ff4e7dbc7f1a32a61c8116447 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 25 Jun 2026 20:20:08 -0700 Subject: [PATCH 08/34] perf(moq-json): generate merge patches with a diffing serializer (+ benchmark) (#1912) Co-authored-by: Claude --- Cargo.lock | 215 ++++++- rs/moq-json/Cargo.toml | 7 + rs/moq-json/benches/codec.rs | 367 ++++++++++++ rs/moq-json/src/diff.rs | 1026 ++++++++++++++++++++++++++++++++-- rs/moq-json/src/lib.rs | 117 ++-- 5 files changed, 1597 insertions(+), 135 deletions(-) create mode 100644 rs/moq-json/benches/codec.rs diff --git a/Cargo.lock b/Cargo.lock index 3b73e0f23..93c8aeaa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -104,6 +113,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -140,7 +155,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -151,7 +166,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -776,6 +791,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbindgen" version = "0.29.4" @@ -897,6 +918,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1170,6 +1218,41 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -1194,6 +1277,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1209,6 +1302,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -1783,7 +1882,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2283,7 +2382,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2476,6 +2575,17 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "253b313319f7109de64e480ffb606f89475cd758bae82e096e00c5d95341d30e" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hang" version = "0.19.2" @@ -3287,7 +3397,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3858,6 +3968,7 @@ name = "moq-json" version = "0.1.0" dependencies = [ "bytes", + "criterion", "json-patch", "kio", "moq-flate", @@ -4452,7 +4563,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4909,6 +5020,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -5011,6 +5128,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "papaya" version = "0.2.4" @@ -5206,6 +5333,34 @@ dependencies = [ "time", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "pollster" version = "0.4.0" @@ -5724,6 +5879,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rcgen" version = "0.14.8" @@ -5991,7 +6166,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6004,7 +6179,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6063,7 +6238,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6084,7 +6259,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6299,7 +6474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6710,7 +6885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6934,7 +7109,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6943,7 +7118,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7088,6 +7263,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -8221,7 +8406,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 9063aed5e..aaf984049 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -24,3 +24,10 @@ moq-net = { workspace = true } serde = { workspace = true } serde_json = "1" thiserror = "2" + +[dev-dependencies] +criterion = "0.8" + +[[bench]] +name = "codec" +harness = false diff --git a/rs/moq-json/benches/codec.rs b/rs/moq-json/benches/codec.rs new file mode 100644 index 000000000..63719913c --- /dev/null +++ b/rs/moq-json/benches/codec.rs @@ -0,0 +1,367 @@ +//! CPU benchmarks for the raw codec operations behind `moq-json`, with no Producer/Consumer or +//! moq-net framing in the loop. Each measures one step of the per-delta pipeline: +//! +//! 1. `encode_patch` - generate a merge patch from the old value and the new one ([`diff`]). +//! 2. `decode_patch` - apply a merge patch to reconstruct the new value (`json_patch::merge`), with +//! a consuming variant (`merge_owned`) for comparison. +//! 3. `deflate` - compress a delta into a warm DEFLATE window. +//! 4. `inflate` - decompress a delta from a warm DEFLATE window. +//! 5. `producer` - the full producer step: encode patch, serialize, deflate. +//! 6. `consumer` - the full consumer step: inflate, parse, merge. +//! +//! Run with `cargo bench -p moq-json`. + +use std::hint::black_box; + +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use moq_flate::{Decoder, Encoder}; +use moq_json::diff; +use serde_json::{Map, Value, json}; + +/// One second of telemetry: a big static core plus a few moving numbers. Most fields change a little +/// each tick, so the delta is small but touches much of the document. +fn telemetry(tick: u64) -> Value { + let t = tick as f64; + let lat = 37.7749 + (t * 0.0001).sin() * 0.01; + let lon = -122.4194 + (t * 0.0001).cos() * 0.01; + + json!({ + "device": { + "id": "veh-4417-a2", + "model": "Sentinel X2", + "firmware": "4.18.2-rc1", + "serial": "SNX2-0000-4417-A2C9", + "region": "us-west-2", + "fleet": "logistics-prod", + "tags": ["cold-chain", "long-haul", "priority"], + }, + "config": { + "sample_hz": 1, + "upload_hz": 1, + "geofence": "bay-area", + "thresholds": { "temp_c": 8.0, "humidity": 85, "shock_g": 3.5, "battery_pct": 15 }, + "contacts": ["ops@example.com", "fleet@example.com"], + }, + "ts": 1_700_000_000 + tick, + "uptime_s": tick, + "location": { + "lat": (lat * 1e6).round() / 1e6, + "lon": (lon * 1e6).round() / 1e6, + "alt_m": 12 + (tick % 5), + "heading": (tick * 7) % 360, + "speed_kph": 40 + (tick % 25), + "fix": "3d", + "sats": 9 + (tick % 3), + }, + "sensors": { + "temp_c": ((4.0 + (t * 0.05).sin() * 1.5) * 100.0).round() / 100.0, + "humidity": 60 + (tick % 10), + "shock_g": (((t * 0.3).sin().abs()) * 100.0).round() / 100.0, + "door_open": tick % 30 == 0, + }, + "power": { + "battery_pct": 100 - (tick / 6) % 100, + "charging": false, + "voltage_mv": 12_400 - (tick % 50) as i64, + "current_ma": 850 + (tick % 120) as i64, + }, + "network": { + "rssi_dbm": -70 - (tick % 15) as i64, + "type": "lte", + "bytes_up": 1_024 * tick, + "bytes_down": 256 * tick, + "latency_ms": 35 + (tick % 40), + }, + "counters": { + "events": tick, + "errors": tick / 50, + "reconnects": tick / 120, + }, + }) +} + +/// A large mostly-static document: a big config blob that never changes plus a few counters that +/// tick. The delta is tiny relative to the document. +fn big_static(tick: u64) -> Value { + let routes: Vec = (0..80) + .map(|i| { + json!({ + "id": format!("route-{i:04}"), + "cidr": format!("10.{}.{}.0/24", i / 16, i % 16), + "gateway": format!("10.0.{i}.1"), + "metric": 100 + i, + "enabled": true, + "tags": ["prod", "egress", "monitored"], + }) + }) + .collect(); + + json!({ + "meta": { "version": "9.2.1", "node": "edge-router-77", "region": "us-east-1" }, + "routes": routes, + "counters": { + "packets_in": 1_000_000 + tick * 137, + "packets_out": 990_000 + tick * 131, + "errors": tick / 7, + "uptime_s": tick, + }, + }) +} + +/// RFC 7396 merge that consumes the patch, moving values into the target instead of cloning them like +/// `json_patch::merge` (which takes `&Value`). Used to test whether a consuming merge decodes faster. +fn merge_owned(target: &mut Value, patch: Value) { + let Value::Object(patch) = patch else { + *target = patch; + return; + }; + if !target.is_object() { + *target = Value::Object(Map::new()); + } + let map = target.as_object_mut().unwrap(); + for (key, value) in patch { + if value.is_null() { + map.remove(&key); + } else { + merge_owned(map.entry(key).or_insert(Value::Null), value); + } + } +} + +/// One workload reduced to a single old -> new transition and the artifacts each op needs: the patch +/// value, the serialized snapshot and patch, and the compressed snapshot/delta slices (the delta is +/// compressed against a window already holding the snapshot, matching the real per-group stream). +struct Fixture { + name: &'static str, + old: Value, + new: Value, + patch: Value, + snapshot_bytes: Vec, + patch_bytes: Vec, + snapshot_slice: Vec, + delta_slice: Vec, + // The full new value compressed against a window already holding the old snapshot: what a + // snapshot-only stream (no merge patch) would send each tick, the fair baseline for the delta path. + new_slice: Vec, +} + +impl Fixture { + fn new(name: &'static str, make: fn(u64) -> Value) -> Self { + let old = make(0); + let new = make(1); + let patch = diff(&old, &new).patch; + let snapshot_bytes = serde_json::to_vec(&old).unwrap(); + let patch_bytes = serde_json::to_vec(&patch).unwrap(); + let new_bytes = serde_json::to_vec(&new).unwrap(); + + // Snapshot then delta (the merge-patch stream). + let mut enc = Encoder::new(); + let snapshot_slice = enc.frame(&snapshot_bytes).to_vec(); + let delta_slice = enc.frame(&patch_bytes).to_vec(); + + // Snapshot then the full new snapshot again (the snapshot-only stream). + let mut enc = Encoder::new(); + enc.frame(&snapshot_bytes); + let new_slice = enc.frame(&new_bytes).to_vec(); + + Self { + name, + old, + new, + patch, + snapshot_bytes, + patch_bytes, + snapshot_slice, + delta_slice, + new_slice, + } + } + + /// A DEFLATE encoder warmed with the snapshot, ready to compress the delta as the next frame. + fn warm_encoder(&self) -> Encoder { + let mut enc = Encoder::new(); + enc.frame(&self.snapshot_bytes); + enc + } + + /// A DEFLATE decoder warmed with the snapshot, ready to decompress the delta as the next frame. + fn warm_decoder(&self) -> Decoder { + let mut dec = Decoder::new(); + dec.frame(&self.snapshot_slice).unwrap(); + dec + } +} + +fn fixtures() -> Vec { + vec![ + Fixture::new("telemetry", telemetry), + Fixture::new("big_static", big_static), + ] +} + +/// 1. Generate a merge patch from the old and new values. +fn encode_patch(c: &mut Criterion) { + let mut group = c.benchmark_group("encode_patch"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.snapshot_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::from_parameter(f.name), f, |b, f| { + b.iter(|| black_box(diff(&f.old, &f.new))); + }); + } + group.finish(); +} + +/// 2. Apply a merge patch to reconstruct the new value, json_patch (borrowing) vs consuming merge. +fn decode_patch(c: &mut Criterion) { + let mut group = c.benchmark_group("decode_patch"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.snapshot_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::new("json_patch", f.name), f, |b, f| { + b.iter_batched( + || f.old.clone(), + |mut current| { + json_patch::merge(&mut current, &f.patch); + black_box(current); + }, + BatchSize::SmallInput, + ); + }); + group.bench_with_input(BenchmarkId::new("merge_owned", f.name), f, |b, f| { + b.iter_batched( + || (f.old.clone(), f.patch.clone()), + |(mut current, patch)| { + merge_owned(&mut current, patch); + black_box(current); + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +/// 3. Compress a delta into a warm DEFLATE window. +fn deflate(c: &mut Criterion) { + let mut group = c.benchmark_group("deflate"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.patch_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::from_parameter(f.name), f, |b, f| { + b.iter_batched( + || f.warm_encoder(), + |mut enc| black_box(enc.frame(&f.patch_bytes)), + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +/// 4. Decompress a delta from a warm DEFLATE window. +fn inflate(c: &mut Criterion) { + let mut group = c.benchmark_group("inflate"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.patch_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::from_parameter(f.name), f, |b, f| { + b.iter_batched( + || f.warm_decoder(), + |mut dec| black_box(dec.frame(&f.delta_slice).unwrap()), + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +/// The marshal each path pays per tick: a full `to_vec` of the document (snapshot-only) vs a `to_vec` +/// of just the patch (delta). The diff already walks the whole document, so this is the extra cost +/// the snapshot path carries that the delta path avoids. +fn marshal(c: &mut Criterion) { + let mut group = c.benchmark_group("marshal"); + for f in &fixtures() { + group.bench_with_input(BenchmarkId::new("full", f.name), f, |b, f| { + b.iter(|| black_box(serde_json::to_vec(&f.new).unwrap())); + }); + group.bench_with_input(BenchmarkId::new("patch", f.name), f, |b, f| { + b.iter(|| black_box(serde_json::to_vec(&f.patch).unwrap())); + }); + } + group.finish(); +} + +/// The full producer step, head to head: the delta path (diff + serialize patch + deflate) vs the +/// snapshot-only path (serialize the whole document + deflate it), both into a warm window. The +/// snapshot path is charged for the full marshal it pays every tick. +fn producer(c: &mut Criterion) { + let mut group = c.benchmark_group("producer"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.snapshot_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::new("merge", f.name), f, |b, f| { + b.iter_batched( + || f.warm_encoder(), + |mut enc| { + let patch = diff(&f.old, &f.new).patch; + let bytes = serde_json::to_vec(&patch).unwrap(); + black_box(enc.frame(&bytes)); + }, + BatchSize::SmallInput, + ); + }); + group.bench_with_input(BenchmarkId::new("snapshot", f.name), f, |b, f| { + b.iter_batched( + || f.warm_encoder(), + |mut enc| { + let bytes = serde_json::to_vec(&f.new).unwrap(); + black_box(enc.frame(&bytes)); + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +/// The full consumer step, head to head: the delta path (inflate + parse patch + merge) vs the +/// snapshot-only path (inflate + parse the whole document). The snapshot path re-parses the entire +/// document every tick; the delta path parses only the patch. +fn consumer(c: &mut Criterion) { + let mut group = c.benchmark_group("consumer"); + for f in &fixtures() { + group.throughput(Throughput::Bytes(f.snapshot_bytes.len() as u64)); + group.bench_with_input(BenchmarkId::new("merge", f.name), f, |b, f| { + b.iter_batched( + || (f.warm_decoder(), f.old.clone()), + |(mut dec, mut current)| { + let plain = dec.frame(&f.delta_slice).unwrap(); + let patch: Value = serde_json::from_slice(&plain).unwrap(); + json_patch::merge(&mut current, &patch); + black_box(current); + }, + BatchSize::SmallInput, + ); + }); + group.bench_with_input(BenchmarkId::new("snapshot", f.name), f, |b, f| { + b.iter_batched( + || f.warm_decoder(), + |mut dec| { + let plain = dec.frame(&f.new_slice).unwrap(); + let value: Value = serde_json::from_slice(&plain).unwrap(); + black_box(value); + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +criterion_group!( + benches, + encode_patch, + decode_patch, + deflate, + inflate, + marshal, + producer, + consumer +); +criterion_main!(benches); diff --git a/rs/moq-json/src/diff.rs b/rs/moq-json/src/diff.rs index 117c5afe2..d07b1417e 100644 --- a/rs/moq-json/src/diff.rs +++ b/rs/moq-json/src/diff.rs @@ -1,67 +1,572 @@ +//! Generate an [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396.html) JSON Merge Patch directly +//! from a value, diffing it against the previously published value as it is serialized. +//! +//! A serde [`Serializer`] walks the new value and compares each field against the corresponding +//! node of the old [`Value`], so unchanged scalars and subtrees cost only a comparison (no +//! allocation) and only changed nodes are built into the patch. This avoids materializing a full +//! `Value` tree for the new value just to diff two trees. + +use std::cell::Cell; +use std::collections::HashSet; + +use serde::Serialize; +use serde::ser::{Impossible, SerializeMap, SerializeSeq, SerializeStruct, Serializer}; use serde_json::{Map, Value}; -/// The result of diffing two JSON values into an RFC 7396 merge patch. +/// The result of diffing a value into an RFC 7396 merge patch. pub struct Diff { /// A merge patch that transforms the old value into the new one. pub patch: Value, - /// Set when the change can't be faithfully expressed as a merge patch, so the caller - /// should publish a full snapshot instead. This happens when a value is set to JSON null, - /// which merge patch reads as a key deletion. Arrays are fine: merge patch replaces them - /// wholesale, which is still typically smaller than a full snapshot. + /// Set when the change can't be faithfully expressed as a merge patch, so the caller should + /// publish a full snapshot instead. This happens when a value is set to JSON null, which merge + /// patch reads as a key deletion, or when the root is not an object. Arrays are fine: merge patch + /// replaces them wholesale, which is still typically smaller than a full snapshot. pub forced_snapshot: bool, } /// Generate an RFC 7396 merge patch transforming `old` into `new`. /// -/// Only object roots produce a recursive patch; any other root forces a snapshot. -pub fn diff(old: &Value, new: &Value) -> Diff { - if let (Value::Object(old), Value::Object(new)) = (old, new) { - let mut patch = Map::new(); - let mut forced = false; - diff_objects(old, new, &mut patch, &mut forced); - Diff { - patch: Value::Object(patch), - forced_snapshot: forced, +/// Only object roots produce a recursive patch; any other root forces a snapshot. A merge patch that +/// would delete a key it shouldn't (a value genuinely set to null) also forces a snapshot. +pub fn diff(old: &Value, new: &T) -> Diff { + let forced = Cell::new(false); + let node = new.serialize(Differ { + baseline: old, + forced: &forced, + }); + + match node { + // No field differed: an empty patch. A null somewhere may still have forced a snapshot. + Ok(Node::Same) => Diff { + patch: Value::Object(Map::new()), + forced_snapshot: forced.get(), + }, + // A non-object patch (or non-object baseline) can't be a recursive merge patch, so force a + // snapshot for non-object roots. + Ok(Node::Diff(patch)) => { + let non_object_root = !patch.is_object() || !old.is_object(); + Diff { + patch, + forced_snapshot: forced.get() || non_object_root, + } } - } else { - Diff { - patch: new.clone(), + // A value that isn't representable as JSON (e.g. a non-string map key) can't be diffed. Fall + // back to a snapshot; the caller's own serialization surfaces the real error if there is one. + Err(_) => Diff { + patch: Value::Object(Map::new()), forced_snapshot: true, + }, + } +} + +/// One node's verdict from the diffing serializer. +enum Node { + /// Equal to the baseline; nothing to emit. + Same, + /// Differs; the new value to splice into the patch. + Diff(Value), +} + +const NULL: Value = Value::Null; + +/// Serializer that diffs `T` against `baseline` and yields a merge patch. `forced` is set if a +/// genuine null is emitted (merge patch can't represent it, so the caller must snapshot). +#[derive(Copy, Clone)] +struct Differ<'a> { + baseline: &'a Value, + forced: &'a Cell, +} + +impl<'a> Differ<'a> { + /// The baseline child for `key` and whether the baseline actually had that key (a missing key + /// means the field is an addition, which `MapDiff` uses to keep deletion detection cheap). + fn child(&self, key: &str) -> (Differ<'a>, bool) { + let (baseline, existed) = match self.baseline { + Value::Object(m) => match m.get(key) { + Some(value) => (value, true), + None => (&NULL, false), + }, + _ => (&NULL, false), + }; + ( + Differ { + baseline, + forced: self.forced, + }, + existed, + ) + } + + /// Compare a freshly built scalar/array against the baseline, flagging emitted nulls as forced. + fn scalar(self, value: Value) -> Result { + if self.baseline == &value { + Ok(Node::Same) + } else { + // A genuine null can't be stored: merge patch would read it as a key deletion. + if value.is_null() { + self.forced.set(true); + } + Ok(Node::Diff(value)) } } } -fn diff_objects(old: &Map, new: &Map, patch: &mut Map, forced: &mut bool) { - // Keys present in old but missing from new become explicit null deletions. - for key in old.keys() { - if !new.contains_key(key) { - patch.insert(key.clone(), Value::Null); +/// Minimal serde error for the diffing serializer. JSON-shaped data never produces one in practice. +#[derive(Debug)] +struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +impl serde::ser::Error for Error { + fn custom(msg: M) -> Self { + Error(msg.to_string()) + } +} + +/// Build a Value with no diffing (used for array elements, which merge patch replaces wholesale). +fn to_plain(value: &T) -> Result { + serde_json::to_value(value).map_err(|e| Error(e.to_string())) +} + +impl<'a> Serializer for Differ<'a> { + type Ok = Node; + type Error = Error; + type SerializeSeq = SeqDiff<'a>; + type SerializeTuple = SeqDiff<'a>; + type SerializeTupleStruct = SeqDiff<'a>; + type SerializeTupleVariant = VariantSeq<'a>; + type SerializeMap = MapDiff<'a>; + type SerializeStruct = MapDiff<'a>; + type SerializeStructVariant = VariantMap<'a>; + + fn serialize_bool(self, v: bool) -> Result { + self.scalar(Value::Bool(v)) + } + fn serialize_i8(self, v: i8) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_i16(self, v: i16) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_i32(self, v: i32) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_i64(self, v: i64) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_i128(self, v: i128) -> Result { + self.scalar(to_plain(&v)?) + } + fn serialize_u8(self, v: u8) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_u16(self, v: u16) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_u32(self, v: u32) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_u64(self, v: u64) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_u128(self, v: u128) -> Result { + self.scalar(to_plain(&v)?) + } + fn serialize_f32(self, v: f32) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_f64(self, v: f64) -> Result { + self.scalar(Value::from(v)) + } + fn serialize_char(self, v: char) -> Result { + self.scalar(Value::from(v.to_string())) + } + fn serialize_str(self, v: &str) -> Result { + // Strings are the common churn-free field, so compare against the baseline without allocating a + // `Value::String` on the unchanged path. + if matches!(self.baseline, Value::String(b) if b == v) { + Ok(Node::Same) + } else { + Ok(Node::Diff(Value::from(v))) } } + fn serialize_bytes(self, v: &[u8]) -> Result { + self.scalar(to_plain(v)?) + } + fn serialize_none(self) -> Result { + self.scalar(Value::Null) + } + fn serialize_some(self, value: &T) -> Result { + value.serialize(self) + } + fn serialize_unit(self) -> Result { + self.scalar(Value::Null) + } + fn serialize_unit_struct(self, _name: &'static str) -> Result { + self.scalar(Value::Null) + } + fn serialize_unit_variant(self, _name: &'static str, _idx: u32, variant: &'static str) -> Result { + self.scalar(Value::from(variant)) + } + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> Result { + value.serialize(self) + } + fn serialize_newtype_variant( + self, + _name: &'static str, + _idx: u32, + variant: &'static str, + value: &T, + ) -> Result { + // An externally-tagged newtype variant serializes as `{ "Variant": value }`. Diff that object + // against the baseline like any other object, so the tag is preserved and the payload diffs + // minimally (a variant switch deletes the old tag and adds the new one). + variant_object(variant, to_plain(value)?).serialize(self) + } + fn serialize_seq(self, len: Option) -> Result, Error> { + Ok(SeqDiff { + differ: self, + items: Vec::with_capacity(len.unwrap_or(0)), + }) + } + fn serialize_tuple(self, len: usize) -> Result, Error> { + self.serialize_seq(Some(len)) + } + fn serialize_tuple_struct(self, _name: &'static str, len: usize) -> Result, Error> { + self.serialize_seq(Some(len)) + } + fn serialize_tuple_variant( + self, + _name: &'static str, + _idx: u32, + variant: &'static str, + len: usize, + ) -> Result, Error> { + // A tuple variant serializes as `{ "Variant": [..] }`, replaced wholesale. + Ok(VariantSeq { + differ: self, + variant, + items: Vec::with_capacity(len), + }) + } + fn serialize_map(self, _len: Option) -> Result, Error> { + Ok(MapDiff { + differ: self, + patch: Map::new(), + seen: Vec::new(), + added_key: false, + pending_key: None, + }) + } + fn serialize_struct(self, _name: &'static str, len: usize) -> Result, Error> { + self.serialize_map(Some(len)) + } + fn serialize_struct_variant( + self, + _name: &'static str, + _idx: u32, + variant: &'static str, + _len: usize, + ) -> Result, Error> { + // A struct variant serializes as `{ "Variant": { .. } }`, replaced wholesale. + Ok(VariantMap { + differ: self, + variant, + fields: Map::new(), + }) + } +} + +/// Wrap a value as an externally-tagged variant object `{ variant: value }`. +fn variant_object(variant: &str, value: Value) -> Value { + let mut object = Map::new(); + object.insert(variant.to_owned(), value); + Value::Object(object) +} + +/// Collects a tuple variant's fields into `{ variant: [..] }`, then diffs it against the baseline. +struct VariantSeq<'a> { + differ: Differ<'a>, + variant: &'static str, + items: Vec, +} + +impl serde::ser::SerializeTupleVariant for VariantSeq<'_> { + type Ok = Node; + type Error = Error; + fn serialize_field(&mut self, value: &T) -> Result<(), Error> { + self.items.push(to_plain(value)?); + Ok(()) + } + fn end(self) -> Result { + variant_object(self.variant, Value::Array(self.items)).serialize(self.differ) + } +} + +/// Collects a struct variant's fields into `{ variant: { .. } }`, then diffs it against the baseline. +struct VariantMap<'a> { + differ: Differ<'a>, + variant: &'static str, + fields: Map, +} - for (key, new_val) in new { - let old_val = old.get(key); - if old_val == Some(new_val) { - continue; +impl serde::ser::SerializeStructVariant for VariantMap<'_> { + type Ok = Node; + type Error = Error; + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<(), Error> { + self.fields.insert(key.to_owned(), to_plain(value)?); + Ok(()) + } + fn end(self) -> Result { + variant_object(self.variant, Value::Object(self.fields)).serialize(self.differ) + } +} + +/// Arrays are replaced wholesale by merge patch, so this builds the full new array and compares it +/// to the baseline in one shot. +struct SeqDiff<'a> { + differ: Differ<'a>, + items: Vec, +} + +impl SerializeSeq for SeqDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_element(&mut self, value: &T) -> Result<(), Error> { + self.items.push(to_plain(value)?); + Ok(()) + } + fn end(self) -> Result { + self.differ.scalar(Value::Array(self.items)) + } +} + +impl serde::ser::SerializeTuple for SeqDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_element(&mut self, value: &T) -> Result<(), Error> { + SerializeSeq::serialize_element(self, value) + } + fn end(self) -> Result { + SerializeSeq::end(self) + } +} + +impl serde::ser::SerializeTupleStruct for SeqDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_field(&mut self, value: &T) -> Result<(), Error> { + SerializeSeq::serialize_element(self, value) + } + fn end(self) -> Result { + SerializeSeq::end(self) + } +} + +/// Objects recurse: each entry diffs against the baseline's child, and only changed entries land in +/// the patch. Keys present in the baseline but absent now become explicit null deletions. +struct MapDiff<'a> { + differ: Differ<'a>, + patch: Map, + seen: Vec, + // Set when a field's key was absent from the baseline. Lets `finish` skip the deletion scan when + // the new keys are exactly the baseline keys (the common, churn-free case). + added_key: bool, + pending_key: Option, +} + +impl MapDiff<'_> { + fn entry(&mut self, key: String, existed: bool, node: Node) { + self.added_key |= !existed; + if let Node::Diff(value) = node { + self.patch.insert(key.clone(), value); } + self.seen.push(key); + } - // Recurse into nested objects so unchanged sibling keys stay out of the patch. - if let (Some(Value::Object(old_obj)), Value::Object(new_obj)) = (old_val, new_val) { - let mut sub = Map::new(); - diff_objects(old_obj, new_obj, &mut sub, forced); - if !sub.is_empty() { - patch.insert(key.clone(), Value::Object(sub)); + fn finish(self) -> Result { + let mut patch = self.patch; + if let Value::Object(base) = self.differ.baseline { + // A deletion is only possible if some key was added or the counts differ. Otherwise the new + // keys are exactly the baseline keys, so there's nothing to delete and we skip the scan, + // keeping the common path O(1) rather than O(n^2). A removed key is a clean delete (explicit + // null), and unlike a value set to null it does not force a snapshot. + if self.added_key || self.seen.len() != base.len() { + let seen: HashSet<&str> = self.seen.iter().map(String::as_str).collect(); + for key in base.keys() { + if !seen.contains(key.as_str()) { + patch.insert(key.clone(), Value::Null); + } + } } - continue; } - - // Added or replaced with a non-object value. A literal null can't be stored: merge patch - // would delete the key. Arrays are kept in the patch and replace the target wholesale. - if new_val.is_null() { - *forced = true; + if patch.is_empty() { + Ok(Node::Same) + } else { + Ok(Node::Diff(Value::Object(patch))) } - patch.insert(key.clone(), new_val.clone()); + } +} + +impl SerializeMap for MapDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_key(&mut self, key: &T) -> Result<(), Error> { + // Extract the key string in a single allocation (no intermediate Value). + self.pending_key = Some(key.serialize(KeySer)?); + Ok(()) + } + fn serialize_value(&mut self, value: &T) -> Result<(), Error> { + let key = self.pending_key.take().expect("serialize_key precedes serialize_value"); + let (child, existed) = self.differ.child(&key); + let node = value.serialize(child)?; + self.entry(key, existed, node); + Ok(()) + } + fn end(self) -> Result { + self.finish() + } +} + +impl SerializeStruct for MapDiff<'_> { + type Ok = Node; + type Error = Error; + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<(), Error> { + let (child, existed) = self.differ.child(key); + let node = value.serialize(child)?; + self.entry(key.to_owned(), existed, node); + Ok(()) + } + // A field skipped via `skip_serializing_if` is simply never offered here, so it stays out of `seen` + // and `finish` emits it as a null deletion if the baseline had it (the default `skip_field` suffices). + fn end(self) -> Result { + self.finish() + } +} + +/// Serializes a map key to its `String`, the only form JSON object keys take. Anything else is an +/// error, mirroring `serde_json`'s own key handling. +struct KeySer; + +impl Serializer for KeySer { + type Ok = String; + type Error = Error; + type SerializeSeq = Impossible; + type SerializeTuple = Impossible; + type SerializeTupleStruct = Impossible; + type SerializeTupleVariant = Impossible; + type SerializeMap = Impossible; + type SerializeStruct = Impossible; + type SerializeStructVariant = Impossible; + + fn serialize_str(self, v: &str) -> Result { + Ok(v.to_owned()) + } + fn serialize_char(self, v: char) -> Result { + Ok(v.to_string()) + } + fn serialize_bool(self, v: bool) -> Result { + Ok(v.to_string()) + } + fn serialize_i8(self, v: i8) -> Result { + Ok(v.to_string()) + } + fn serialize_i16(self, v: i16) -> Result { + Ok(v.to_string()) + } + fn serialize_i32(self, v: i32) -> Result { + Ok(v.to_string()) + } + fn serialize_i64(self, v: i64) -> Result { + Ok(v.to_string()) + } + fn serialize_u8(self, v: u8) -> Result { + Ok(v.to_string()) + } + fn serialize_u16(self, v: u16) -> Result { + Ok(v.to_string()) + } + fn serialize_u32(self, v: u32) -> Result { + Ok(v.to_string()) + } + fn serialize_u64(self, v: u64) -> Result { + Ok(v.to_string()) + } + fn serialize_unit_variant(self, _name: &'static str, _idx: u32, variant: &'static str) -> Result { + Ok(variant.to_owned()) + } + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> Result { + value.serialize(self) + } + fn serialize_some(self, value: &T) -> Result { + value.serialize(self) + } + fn serialize_f32(self, _v: f32) -> Result { + Err(Error("float map key".into())) + } + fn serialize_f64(self, _v: f64) -> Result { + Err(Error("float map key".into())) + } + fn serialize_bytes(self, _v: &[u8]) -> Result { + Err(Error("bytes map key".into())) + } + fn serialize_none(self) -> Result { + Err(Error("null map key".into())) + } + fn serialize_unit(self) -> Result { + Err(Error("unit map key".into())) + } + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Err(Error("unit struct map key".into())) + } + fn serialize_newtype_variant( + self, + _name: &'static str, + _idx: u32, + _variant: &'static str, + _value: &T, + ) -> Result { + Err(Error("newtype variant map key".into())) + } + fn serialize_seq(self, _len: Option) -> Result { + Err(Error("seq map key".into())) + } + fn serialize_tuple(self, _len: usize) -> Result { + Err(Error("tuple map key".into())) + } + fn serialize_tuple_struct(self, _name: &'static str, _len: usize) -> Result { + Err(Error("tuple struct map key".into())) + } + fn serialize_tuple_variant( + self, + _name: &'static str, + _idx: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error("tuple variant map key".into())) + } + fn serialize_map(self, _len: Option) -> Result { + Err(Error("map map key".into())) + } + fn serialize_struct(self, _name: &'static str, _len: usize) -> Result { + Err(Error("struct map key".into())) + } + fn serialize_struct_variant( + self, + _name: &'static str, + _idx: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + Err(Error("struct variant map key".into())) } } @@ -70,18 +575,76 @@ mod test { use super::*; use serde_json::json; - /// Applying the patch to old should reproduce new (RFC 7396 semantics). - fn assert_roundtrip(old: Value, new: Value) { - let result = diff(&old, &new); - assert!(!result.forced_snapshot, "expected a delta, got a forced snapshot"); - let mut applied = old; - json_patch::merge(&mut applied, &result.patch); - assert_eq!(applied, new); + /// A straightforward Value-vs-Value merge-patch diff, used only as a test oracle: the production + /// `diff` (the serializer) must agree with it on every case. + fn reference(old: &Value, new: &Value) -> Diff { + fn objects( + old: &Map, + new: &Map, + patch: &mut Map, + forced: &mut bool, + ) { + for key in old.keys() { + if !new.contains_key(key) { + patch.insert(key.clone(), Value::Null); + } + } + for (key, new_val) in new { + let old_val = old.get(key); + if old_val == Some(new_val) { + continue; + } + if let (Some(Value::Object(old_obj)), Value::Object(new_obj)) = (old_val, new_val) { + let mut sub = Map::new(); + objects(old_obj, new_obj, &mut sub, forced); + if !sub.is_empty() { + patch.insert(key.clone(), Value::Object(sub)); + } + continue; + } + if new_val.is_null() { + *forced = true; + } + patch.insert(key.clone(), new_val.clone()); + } + } + + if let (Value::Object(old_obj), Value::Object(new_obj)) = (old, new) { + let mut patch = Map::new(); + let mut forced = false; + objects(old_obj, new_obj, &mut patch, &mut forced); + Diff { + patch: Value::Object(patch), + forced_snapshot: forced, + } + } else { + Diff { + patch: new.clone(), + forced_snapshot: true, + } + } + } + + /// The serializer must produce the same patch and forced flag as the reference oracle, and (when + /// not forced) applying the patch to `old` must reproduce `new`. + fn check(old: Value, new: Value) { + let want = reference(&old, &new); + let got = diff(&old, &new); + assert_eq!(got.patch, want.patch, "patch mismatch for {old} -> {new}"); + assert_eq!( + got.forced_snapshot, want.forced_snapshot, + "forced mismatch for {old} -> {new}" + ); + if !got.forced_snapshot { + let mut applied = old.clone(); + json_patch::merge(&mut applied, &got.patch); + assert_eq!(applied, new, "patch did not roundtrip for {old} -> {new}"); + } } #[test] fn changed_scalar() { - assert_roundtrip(json!({ "a": 1, "b": 2 }), json!({ "a": 1, "b": 3 })); + check(json!({ "a": 1, "b": 2 }), json!({ "a": 1, "b": 3 })); } #[test] @@ -89,6 +652,7 @@ mod test { let result = diff(&json!({ "a": 1 }), &json!({ "a": 1, "b": 2 })); assert!(!result.forced_snapshot); assert_eq!(result.patch, json!({ "b": 2 })); + check(json!({ "a": 1 }), json!({ "a": 1, "b": 2 })); } #[test] @@ -96,7 +660,7 @@ mod test { let result = diff(&json!({ "a": 1, "b": 2 }), &json!({ "a": 1 })); assert!(!result.forced_snapshot, "removing a key is a clean delete"); assert_eq!(result.patch, json!({ "b": null })); - assert_roundtrip(json!({ "a": 1, "b": 2 }), json!({ "a": 1 })); + check(json!({ "a": 1, "b": 2 }), json!({ "a": 1 })); } #[test] @@ -104,6 +668,14 @@ mod test { let result = diff(&json!({ "o": { "x": 1, "y": 2 } }), &json!({ "o": { "x": 1, "y": 9 } })); assert!(!result.forced_snapshot); assert_eq!(result.patch, json!({ "o": { "y": 9 } })); + check(json!({ "o": { "x": 1, "y": 2 } }), json!({ "o": { "x": 1, "y": 9 } })); + } + + #[test] + fn unchanged_object_is_empty_patch() { + let result = diff(&json!({ "a": 1, "o": { "x": 1 } }), &json!({ "a": 1, "o": { "x": 1 } })); + assert!(!result.forced_snapshot); + assert_eq!(result.patch, json!({})); } #[test] @@ -111,22 +683,35 @@ mod test { let result = diff(&json!({ "a": [1, 2] }), &json!({ "a": [1, 2, 3] })); assert!(!result.forced_snapshot); assert_eq!(result.patch, json!({ "a": [1, 2, 3] })); - assert_roundtrip(json!({ "a": [1, 2] }), json!({ "a": [1, 2, 3] })); + check(json!({ "a": [1, 2] }), json!({ "a": [1, 2, 3] })); + } + + #[test] + fn unchanged_array_is_pruned() { + let result = diff(&json!({ "a": [1, 2, 3], "b": 1 }), &json!({ "a": [1, 2, 3], "b": 2 })); + assert_eq!( + result.patch, + json!({ "b": 2 }), + "an unchanged array stays out of the patch" + ); } #[test] fn added_array_is_delta() { - let result = diff(&json!({ "a": 1 }), &json!({ "a": 1, "b": [1] })); - assert!(!result.forced_snapshot); - assert_eq!(result.patch, json!({ "b": [1] })); + check(json!({ "a": 1 }), json!({ "a": 1, "b": [1] })); } #[test] fn nested_array_is_delta() { - let result = diff(&json!({ "o": { "x": 1 } }), &json!({ "o": { "x": 1, "list": [1] } })); - assert!(!result.forced_snapshot); - assert_eq!(result.patch, json!({ "o": { "list": [1] } })); - assert_roundtrip(json!({ "o": { "x": 1 } }), json!({ "o": { "x": 1, "list": [1] } })); + check(json!({ "o": { "x": 1 } }), json!({ "o": { "x": 1, "list": [1] } })); + } + + #[test] + fn array_of_objects_replaces_wholesale() { + check( + json!({ "items": [{ "id": 1, "v": 1 }, { "id": 2, "v": 2 }] }), + json!({ "items": [{ "id": 1, "v": 9 }, { "id": 2, "v": 2 }] }), + ); } #[test] @@ -134,17 +719,324 @@ mod test { // A genuine null value can't be represented: merge patch would delete the key. let result = diff(&json!({ "a": 1 }), &json!({ "a": null })); assert!(result.forced_snapshot); + assert!(reference(&json!({ "a": 1 }), &json!({ "a": null })).forced_snapshot); + } + + #[test] + fn nested_null_forces_snapshot() { + let old = json!({ "o": { "x": 1 } }); + let new = json!({ "o": { "x": null } }); + assert!(diff(&old, &new).forced_snapshot); + assert_eq!(diff(&old, &new).forced_snapshot, reference(&old, &new).forced_snapshot); } #[test] fn replacing_object_with_scalar() { - assert_roundtrip(json!({ "a": { "x": 1 } }), json!({ "a": 5 })); + check(json!({ "a": { "x": 1 } }), json!({ "a": 5 })); + } + + #[test] + fn replacing_scalar_with_object() { + check(json!({ "a": 5 }), json!({ "a": { "x": 1 } })); } #[test] fn non_object_root_forces_snapshot() { let result = diff(&json!(1), &json!(2)); assert!(result.forced_snapshot); + assert_eq!(result.patch, json!(2)); + } + + #[test] + fn array_root_forces_snapshot() { + let result = diff(&json!([1, 2]), &json!([1, 2, 3])); + assert!(result.forced_snapshot); + assert_eq!(result.patch, json!([1, 2, 3])); + } + + #[test] + fn unchanged_scalar_root_is_not_forced() { + // An equal non-object root is a no-op (empty patch), matching the producer's dedup. + let result = diff(&json!(7), &json!(7)); + assert!(!result.forced_snapshot); + assert_eq!(result.patch, json!({})); + } + + #[test] + fn floats_and_bools_and_strings() { + check( + json!({ "f": 1.5, "b": true, "s": "hi" }), + json!({ "f": 2.5, "b": false, "s": "bye" }), + ); + } + + // ---- Typed structs (the serializer's whole point: diff `T` without building its Value) ---- + + #[derive(serde::Serialize, serde::Deserialize, Default, PartialEq, Debug)] + struct Doc { + #[serde(skip_serializing_if = "Option::is_none")] + video: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scte35: Option, + count: u64, + tags: Vec, + } + + /// Diff a typed struct against the Value of a prior typed struct; the patch must roundtrip. + fn check_struct(old: &Doc, new: &Doc) { + let old_value = serde_json::to_value(old).unwrap(); + let result = diff(&old_value, new); + + // Cross-check against the oracle fed the equivalent Values. + let new_value = serde_json::to_value(new).unwrap(); + let want = reference(&old_value, &new_value); + assert_eq!(result.patch, want.patch, "struct patch differs from oracle"); + assert_eq!(result.forced_snapshot, want.forced_snapshot); + + if !result.forced_snapshot { + let mut applied = old_value; + json_patch::merge(&mut applied, &result.patch); + assert_eq!(applied, new_value, "struct patch did not roundtrip"); + } + } + + #[test] + fn struct_field_change() { + check_struct( + &Doc { + count: 1, + tags: vec!["a".into()], + ..Default::default() + }, + &Doc { + count: 2, + tags: vec!["a".into()], + ..Default::default() + }, + ); + } + + #[test] + fn struct_option_some_to_none_is_deletion() { + // A skipped field (None) must become a null deletion of the previously-present key. + let result = diff( + &serde_json::to_value(Doc { + video: Some("v1".into()), + count: 1, + ..Default::default() + }) + .unwrap(), + &Doc { + video: None, + count: 1, + ..Default::default() + }, + ); + assert!(!result.forced_snapshot, "deleting a skipped key is clean"); + assert_eq!(result.patch, json!({ "video": null })); + check_struct( + &Doc { + video: Some("v1".into()), + count: 1, + ..Default::default() + }, + &Doc { + video: None, + count: 1, + ..Default::default() + }, + ); + } + + #[test] + fn struct_option_none_to_some_is_addition() { + check_struct( + &Doc { + count: 1, + ..Default::default() + }, + &Doc { + scte35: Some(42), + count: 1, + ..Default::default() + }, + ); + } + + #[test] + fn struct_unchanged_is_empty_patch() { + let doc = Doc { + video: Some("v".into()), + scte35: Some(1), + count: 7, + tags: vec!["x".into(), "y".into()], + }; + let result = diff(&serde_json::to_value(&doc).unwrap(), &doc); + assert!(!result.forced_snapshot); + assert_eq!(result.patch, json!({})); + } + + #[test] + fn struct_vec_changes_wholesale() { + check_struct( + &Doc { + count: 1, + tags: vec!["a".into(), "b".into()], + ..Default::default() + }, + &Doc { + count: 1, + tags: vec!["a".into(), "c".into()], + ..Default::default() + }, + ); + } + + #[derive(serde::Serialize)] + struct Nested { + inner: Inner, + name: String, + } + #[derive(serde::Serialize)] + struct Inner { + a: u32, + b: u32, + } + + #[test] + fn nested_struct_only_changed_field() { + let old = serde_json::to_value(Nested { + inner: Inner { a: 1, b: 2 }, + name: "n".into(), + }) + .unwrap(); + let new = Nested { + inner: Inner { a: 1, b: 9 }, + name: "n".into(), + }; + let result = diff(&old, &new); + assert_eq!(result.patch, json!({ "inner": { "b": 9 } })); + assert!(!result.forced_snapshot); + } + + #[derive(serde::Serialize)] + enum Tag { + Active, + Idle, + } + + #[derive(serde::Serialize)] + struct Stated { + state: Tag, + seq: u32, + } + + #[test] + fn unit_enum_variant_is_string() { + // Externally-tagged unit variants serialize as the variant name string. + let old = serde_json::to_value(Stated { + state: Tag::Active, + seq: 1, + }) + .unwrap(); + assert_eq!(old, json!({ "state": "Active", "seq": 1 })); + let result = diff( + &old, + &Stated { + state: Tag::Idle, + seq: 1, + }, + ); + assert!(!result.forced_snapshot); + assert_eq!(result.patch, json!({ "state": "Idle" })); + } + + /// Diff a typed value against the Value of a prior value: the patch must match the oracle fed the + /// equivalent Values, and roundtrip. + fn check_typed(old: &Value, new: &T) { + let new_value = serde_json::to_value(new).unwrap(); + let want = reference(old, &new_value); + let got = diff(old, new); + assert_eq!(got.patch, want.patch, "patch differs from oracle"); + assert_eq!(got.forced_snapshot, want.forced_snapshot, "forced differs from oracle"); + if !got.forced_snapshot { + let mut applied = old.clone(); + json_patch::merge(&mut applied, &got.patch); + assert_eq!(applied, new_value, "patch did not roundtrip"); + } + } + + #[derive(serde::Serialize)] + enum Payload { + Newtype(u32), + Tuple(u32, String), + Struct { x: u32, y: u32 }, + } + + #[derive(serde::Serialize)] + struct Holder { + payload: Payload, + seq: u32, + } + + #[test] + fn newtype_variant_keeps_its_tag() { + // Regression: a newtype variant must serialize as `{ "Newtype": v }`, not collapse to `v`. + let old = serde_json::to_value(Holder { + payload: Payload::Newtype(1), + seq: 0, + }) + .unwrap(); + assert_eq!(old, json!({ "payload": { "Newtype": 1 }, "seq": 0 })); + let result = diff( + &old, + &Holder { + payload: Payload::Newtype(2), + seq: 0, + }, + ); + assert_eq!(result.patch, json!({ "payload": { "Newtype": 2 } })); + check_typed( + &old, + &Holder { + payload: Payload::Newtype(2), + seq: 0, + }, + ); + } + + #[test] + fn tuple_variant_keeps_its_tag() { + let old = serde_json::to_value(Holder { + payload: Payload::Tuple(1, "a".into()), + seq: 0, + }) + .unwrap(); + assert_eq!(old, json!({ "payload": { "Tuple": [1, "a"] }, "seq": 0 })); + check_typed( + &old, + &Holder { + payload: Payload::Tuple(2, "a".into()), + seq: 0, + }, + ); + } + + #[test] + fn struct_variant_keeps_its_tag() { + let old = serde_json::to_value(Holder { + payload: Payload::Struct { x: 1, y: 2 }, + seq: 0, + }) + .unwrap(); + assert_eq!(old, json!({ "payload": { "Struct": { "x": 1, "y": 2 } }, "seq": 0 })); + check_typed( + &old, + &Holder { + payload: Payload::Struct { x: 1, y: 9 }, + seq: 0, + }, + ); } #[derive(serde::Deserialize)] @@ -173,4 +1065,24 @@ mod test { } } } + + /// Exercise the diff over a sequence of evolving documents, asserting agreement with the oracle + /// and full roundtrip at every step (the way the producer applies deltas). + #[test] + fn evolving_document_matches_oracle() { + let mut docs = Vec::new(); + for tick in 0u64..40 { + docs.push(json!({ + "id": "device-1", + "static": { "model": "x", "tags": ["a", "b", "c"] }, + "counters": { "n": tick, "errors": tick / 10 }, + "reading": (tick as f64 * 0.5), + "flags": { "online": tick % 2 == 0, "charging": tick % 3 == 0 }, + "list": [tick, tick + 1], + })); + } + for pair in docs.windows(2) { + check(pair[0].clone(), pair[1].clone()); + } + } } diff --git a/rs/moq-json/src/lib.rs b/rs/moq-json/src/lib.rs index fffb0d65d..50cc9d5ce 100644 --- a/rs/moq-json/src/lib.rs +++ b/rs/moq-json/src/lib.rs @@ -22,7 +22,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; -use crate::diff::diff; +pub use crate::diff::{Diff, diff}; /// Maximum frames (snapshot + deltas) in a single group before a new snapshot is forced. /// @@ -157,11 +157,7 @@ impl Producer { /// /// Does nothing if the value is unchanged from the previous publish. pub fn update(&mut self, value: &T) -> Result<()> { - let json = serde_json::to_value(value)?; - // Serialize the value directly (not via `json`) so a snapshot preserves the type's own - // field order, keeping the wire bytes identical to serializing `T` straight to a frame. - let snapshot = serde_json::to_vec(value)?; - self.inner.lock().unwrap().update(json, snapshot) + self.inner.lock().unwrap().update(value) } /// Lock the current value for in-place editing, publishing on drop. @@ -229,15 +225,8 @@ impl Drop for Guard<'_, T> { return; } - let Ok(json) = serde_json::to_value(&self.value) else { - return; - }; - let Ok(snapshot) = serde_json::to_vec(&self.value) else { - return; - }; - // We already hold the lock, so publish through the held guard rather than re-locking. - let _ = self.inner.update(json, snapshot); + let _ = self.inner.update(&self.value); } } @@ -259,67 +248,67 @@ struct Inner { } impl Inner { - fn update(&mut self, json: Value, snapshot: Vec) -> Result<()> { - if self.last.as_ref() == Some(&json) { + fn update(&mut self, value: &T) -> Result<()> { + // The first publish (or the first after `finish`) has no baseline to diff against, so it seeds + // the stream with a snapshot. + let Some(last) = self.last.as_ref() else { + return self.snapshot(value); + }; + + // Diff straight off `T`, without building a full `Value` for the new value first. + let Diff { patch, forced_snapshot } = diff(last, value); + + // An empty object patch with no forced null means the value is unchanged: publish nothing. + if !forced_snapshot && patch.as_object().is_some_and(serde_json::Map::is_empty) { return Ok(()); } - match self.delta(&json)? { - Some(slice) => { - let group = self.group.as_mut().expect("delta requires an open group"); - let len = slice.len() as u64; - group.write_frame(slice)?; - self.delta_bytes += len; - self.group_frames += 1; - } - None => self.snapshot(snapshot)?, + // A forced snapshot (a genuine null, or a non-object root) or an exhausted delta budget rolls a + // new group; otherwise the change rides as a delta in the open group. + if forced_snapshot || !self.delta_allowed() { + return self.snapshot(value); } - self.last = Some(json); + // Compress into the per-group window only now, for a frame we are committed to writing. + let bytes = serde_json::to_vec(&patch)?; + let slice = match self.encoder.as_mut() { + Some(encoder) => encoder.frame(&bytes), + None => Bytes::from(bytes), + }; + let len = slice.len() as u64; + self.group + .as_mut() + .expect("delta_allowed guarantees an open group") + .write_frame(slice)?; + self.delta_bytes += len; + self.group_frames += 1; + + // Fold the delta into the baseline so the next diff is against the value we just published. + json_patch::merge(self.last.as_mut().expect("a snapshot precedes any delta"), &patch); Ok(()) } - /// Build a delta frame for `value`, or `None` to signal that a fresh snapshot should be published. + /// Whether the current change may ride as a delta in the open group. /// - /// The budget gate runs first, against the deltas *already written*, so rolling a new group costs - /// no merge-patch or compression work. Because the gate doesn't include the frame about to be - /// written, the delta that tips the group past `ratio * snapshot` still lands: a group overshoots - /// the budget by at most one delta before rolling. - fn delta(&mut self, value: &Value) -> Result> { + /// The budget gate measures the deltas *already written* (excluding the frame about to land) + /// against the group's snapshot frame. Both are compressed sizes when compressing and raw + /// otherwise, so the comparison is like-for-like. Because the pending frame is excluded, the delta + /// that tips the group past `ratio * snapshot` still lands: a group overshoots by at most one delta + /// before rolling. + fn delta_allowed(&self) -> bool { let ratio = self.config.delta_ratio as u64; - if ratio == 0 { - return Ok(None); - } - if self.group.is_none() || self.group_frames >= MAX_DELTA_FRAMES { - return Ok(None); - } - - // Gate on the deltas accumulated so far, measured against the group's snapshot frame. Both are - // compressed sizes when compressing and raw otherwise, so the comparison is always like-for-like. - // Cheap: no diff, no merge patch, no compression yet. - if self.delta_bytes > ratio * self.snapshot_len { - return Ok(None); - } - - let Some(last) = &self.last else { - return Ok(None); - }; - let diff = diff(last, value); - if diff.forced_snapshot { - return Ok(None); - } - let patch = serde_json::to_vec(&diff.patch)?; - - // Compress into the per-group window only now, for a frame we are committed to writing. - let slice = match self.encoder.as_mut() { - Some(encoder) => encoder.frame(&patch), - None => Bytes::from(patch), - }; - Ok(Some(slice)) + ratio != 0 + && self.group.is_some() + && self.group_frames < MAX_DELTA_FRAMES + && self.delta_bytes <= ratio * self.snapshot_len } - /// Start a new group with a full snapshot as its first frame. - fn snapshot(&mut self, snapshot: Vec) -> Result<()> { + /// Start a new group with a full snapshot of `value` as its first frame, and reseed the baseline. + fn snapshot(&mut self, value: &T) -> Result<()> { + // Serialize directly from `value` so the snapshot frame preserves the type's own field order, + // keeping the wire bytes identical to serializing `T` straight to a frame. + let snapshot = serde_json::to_vec(value)?; + // The previous group is complete; no more frames will be appended to it. if let Some(mut group) = self.group.take() { group.finish()?; @@ -351,6 +340,8 @@ impl Inner { group.finish()?; } + // Reseed the baseline with the full new value for the next diff. + self.last = Some(serde_json::to_value(value)?); Ok(()) } From 6b038dfbac47c8703686f6b2d34b2ab707dd0107 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 25 Jun 2026 21:41:35 -0700 Subject: [PATCH 09/34] feat(moq-net): add OriginProducer::dynamic + infallible OriginConsumer::request_broadcast (#1913) Co-authored-by: Claude Opus 4.8 --- rs/kio/src/consumer.rs | 17 +- rs/kio/src/future.rs | 146 ++++++++++ rs/kio/src/lib.rs | 2 + rs/moq-net/src/error.rs | 8 + rs/moq-net/src/model/origin.rs | 492 ++++++++++++++++++++++++++++++++- 5 files changed, 660 insertions(+), 5 deletions(-) create mode 100644 rs/kio/src/future.rs diff --git a/rs/kio/src/consumer.rs b/rs/kio/src/consumer.rs index 768e29da1..1ca75b043 100644 --- a/rs/kio/src/consumer.rs +++ b/rs/kio/src/consumer.rs @@ -6,7 +6,7 @@ use std::{ use crate::{ Counts, State, Weak, lock::*, - producer::{Producer, Ref}, + producer::{Mut, Producer, Ref}, waiter::*, }; @@ -51,6 +51,21 @@ impl Consumer { Poll::Pending } + /// Acquire write access to the shared state from the consumer side. + /// + /// Unlike [`poll`](Self::poll), this never registers a waiter; it simply locks the + /// state for mutation. Returns `Err(`[`Ref`]`)` if the channel is already closed. + /// Mirrors [`Producer::write`], for the rare case where a consumer also feeds shared + /// state back to producers (e.g. a request queue). + pub fn write(&self) -> Result, Ref<'_, T>> { + let state = self.state.lock(); + if state.closed { + Err(Ref { state }) + } else { + Ok(Mut::new(state)) + } + } + /// Poll for channel closure, registering the waiter if still open. pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { let mut state = self.state.lock(); diff --git a/rs/kio/src/future.rs b/rs/kio/src/future.rs new file mode 100644 index 000000000..d05cc65f9 --- /dev/null +++ b/rs/kio/src/future.rs @@ -0,0 +1,146 @@ +use std::{ + ops::{Deref, DerefMut}, + pin::Pin, + task::{Context, Poll}, +}; + +use crate::Waiter; + +/// A pollable computation backed by kio channels. +/// +/// Implementors write only [`Self::poll`], registering the [`Waiter`] with the +/// channels they read. Wrap the value in [`Pending`] to get a real +/// [`std::future::Future`]. +/// +/// This exists because a kio [`Waiter`] holds the strong `Arc` while the +/// channel's [`crate::WaiterList`] keeps only a `Weak`. A bare +/// [`std::future::Future`] would have to stash the strong `Waiter` in a field and +/// replace it every poll (or lose its wakeup); [`Pending`] does that once so each +/// implementor doesn't have to. +pub trait Future: Unpin { + type Output; + + /// Poll for the output, registering `waiter` with the relevant channels if not + /// yet ready. + /// + /// Takes `&self`: kio channels poll immutably, so a pollable can be driven + /// through a shared borrow (e.g. while it lives inside an `&self`-borrowed enum). + /// Carry any per-poll mutable state in a kio channel or a [`std::cell`] type. + fn poll(&self, waiter: &Waiter) -> Poll; +} + +/// Adapts a kio [`Future`] into a [`std::future::Future`], retaining the strong +/// [`Waiter`] between polls so its weak registration stays live. +/// +/// Derefs to the inner value, so any inherent methods you define on it are +/// reachable through the pending handle (e.g. a non-blocking `poll`, or an +/// `update`). +pub struct Pending { + inner: F, + // Retain the previous waiter so its Weak registration survives until the next + // poll replaces it (see [`crate::WaiterList`]). + waiter: Option, +} + +impl Pending { + /// Wrap a [`Future`] so it can be `.await`ed. + pub fn new(inner: F) -> Self { + Self { inner, waiter: None } + } + + /// Consume the wrapper, returning the inner value. + pub fn into_inner(self) -> F { + self.inner + } +} + +impl Deref for Pending { + type Target = F; + + fn deref(&self) -> &F { + &self.inner + } +} + +impl DerefMut for Pending { + fn deref_mut(&mut self) -> &mut F { + &mut self.inner + } +} + +impl std::future::Future for Pending { + type Output = F::Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // Replacing drops the previous waiter, killing its Weak ref in the list so + // the inner poll's register call can recycle the slot (see `WaiterList`). + // `Pending` is `Unpin` (F is, via the trait bound), so this deref is sound. + let this = &mut *self; + this.waiter = Some(Waiter::new(cx.waker().clone())); + Future::poll(&this.inner, this.waiter.as_ref().unwrap()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::Producer; + + /// A pollable that waits for the channel value to reach a threshold, with an + /// inherent method reachable through `Pending`'s `DerefMut`. + struct AtLeast { + consumer: crate::Consumer, + threshold: u64, + } + + impl AtLeast { + fn bump_threshold(&mut self) { + self.threshold += 1; + } + } + + impl Future for AtLeast { + type Output = u64; + + fn poll(&self, waiter: &Waiter) -> Poll { + let threshold = self.threshold; + match self.consumer.poll(waiter, |v| { + let current = **v; + if current >= threshold { + Poll::Ready(current) + } else { + Poll::Pending + } + }) { + Poll::Ready(Ok(v)) => Poll::Ready(v), + _ => Poll::Pending, + } + } + } + + #[test] + fn pending_derefs_and_drives() { + use std::task::Waker; + + let producer = Producer::new(0u64); + let mut pending = Pending::new(AtLeast { + consumer: producer.consume(), + threshold: 5, + }); + + // Inherent method on the inner reached via DerefMut. + pending.bump_threshold(); // threshold now 6 + + // The kio-level poll (reached through Deref) is pending until the value catches up. + assert!(Future::poll(&*pending, &Waiter::noop()).is_pending()); + + if let Ok(mut v) = producer.write() { + *v = 6; + } + + // The std Future resolves once the threshold is met. + let mut cx = Context::from_waker(Waker::noop()); + let mut pending = std::pin::pin!(pending); + assert_eq!(std::future::Future::poll(pending.as_mut(), &mut cx), Poll::Ready(6)); + } +} diff --git a/rs/kio/src/lib.rs b/rs/kio/src/lib.rs index aeda32968..ee76da376 100644 --- a/rs/kio/src/lib.rs +++ b/rs/kio/src/lib.rs @@ -14,6 +14,7 @@ mod lock; mod waiter; mod consumer; +mod future; mod producer; mod weak; @@ -21,6 +22,7 @@ mod weak; mod tests; pub use consumer::Consumer; +pub use future::{Future, Pending}; pub use producer::{Mut, Producer, Ref}; pub use waiter::{Waiter, WaiterList, wait}; pub use weak::Weak; diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index e104c104c..f5630be26 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -49,6 +49,11 @@ pub enum Error { #[error("not found")] NotFound, + /// A broadcast was requested that is neither announced nor served by a dynamic + /// router, so there is no route to it. + #[error("unroutable")] + Unroutable, + #[error("wrong frame size")] WrongSize, @@ -123,6 +128,9 @@ impl Error { Self::Closed => 25, Self::CacheFull => 26, Self::FrameTooLarge => 27, + // 28 (Decompress) and 29 (TimestampMismatch) are reserved on the dev branch; + // keep Unroutable at 30 so the wire code is identical across branches. + Self::Unroutable => 30, Self::App(app) => *app as u32 + 64, Self::Remote(code) => *code, } diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index a74963677..d5248e53c 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -10,7 +10,7 @@ use web_async::Lock; use super::BroadcastConsumer; use crate::{ - AsPath, Broadcast, BroadcastProducer, Path, PathOwned, PathPrefixes, + AsPath, Broadcast, BroadcastProducer, Error, Path, PathOwned, PathPrefixes, coding::{Decode, DecodeError, Encode, EncodeError}, }; @@ -686,6 +686,11 @@ pub struct OriginProducer { // The prefix that is automatically stripped from all paths. root: PathOwned, + + // Fallback request queue, shared with every derived consumer. Separate from + // `nodes` because dynamic broadcasts are never announced: they only resolve a + // consumer's `request_broadcast` when no live announcement exists. + dynamic: kio::Producer, } impl std::ops::Deref for OriginProducer { @@ -704,6 +709,7 @@ impl OriginProducer { info, nodes: OriginNodes::default(), root: PathOwned::default(), + dynamic: kio::Producer::default(), } } @@ -764,12 +770,25 @@ impl OriginProducer { info: self.info, nodes: self.nodes.select(&prefixes)?, root: self.root.clone(), + dynamic: self.dynamic.clone(), }) } + /// Create a dynamic handler that picks up [`OriginConsumer::request_broadcast`] + /// calls for paths that are not announced. + /// + /// This is the origin-level analogue of [`BroadcastProducer::dynamic`]: it serves + /// broadcasts on demand rather than tracks. Crucially the served broadcasts are + /// *not* announced, so [`OriginConsumer::announced`] never sees them; they exist + /// only as a fallback for a consumer that asks for an exact path with no live + /// announcement. Drop the handler (and every clone) to reject pending requests. + pub fn dynamic(&self) -> OriginDynamic { + OriginDynamic::new(self.info, self.root.clone(), self.dynamic.clone()) + } + /// Subscribe to all announced broadcasts. pub fn consume(&self) -> OriginConsumer { - OriginConsumer::new(self.info, self.root.clone(), self.nodes.clone()) + OriginConsumer::new(self.info, self.root.clone(), self.nodes.clone(), self.dynamic.consume()) } /// Get a broadcast by path if it has *already* been published. @@ -795,6 +814,7 @@ impl OriginProducer { info: self.info, root: self.root.join(&prefix).to_owned(), nodes: self.nodes.root(&prefix)?, + dynamic: self.dynamic.clone(), }) } @@ -830,6 +850,10 @@ pub struct OriginConsumer { // A prefix that is automatically stripped from all paths. root: PathOwned, + + // Shared fallback request queue, fed to any `OriginDynamic` handler on the + // producer side. Used only by `request_broadcast`; announced lookups ignore it. + dynamic: kio::Consumer, } impl std::ops::Deref for OriginConsumer { @@ -841,7 +865,7 @@ impl std::ops::Deref for OriginConsumer { } impl OriginConsumer { - fn new(info: Origin, root: PathOwned, nodes: OriginNodes) -> Self { + fn new(info: Origin, root: PathOwned, nodes: OriginNodes, dynamic: kio::Consumer) -> Self { let state = kio::Producer::::default(); let id = ConsumerId::new(); @@ -859,6 +883,7 @@ impl OriginConsumer { nodes, state, root, + dynamic, } } @@ -963,6 +988,7 @@ impl OriginConsumer { self.info, self.root.clone(), self.nodes.select(&prefixes)?, + self.dynamic.clone(), )) } @@ -977,9 +1003,59 @@ impl OriginConsumer { self.info, self.root.join(&prefix).to_owned(), self.nodes.root(&prefix)?, + self.dynamic.clone(), )) } + /// Get a broadcast by path, falling back to a dynamic request when it is not announced. + /// + /// Returns a [`kio::Pending`] future (resolved synchronously for an announced broadcast, + /// otherwise once a handler serves it). The lookup order is: an already-announced broadcast + /// resolves immediately; otherwise, if an [`OriginDynamic`] handler is live (see + /// [`OriginProducer::dynamic`]), a fallback request is registered and the future resolves + /// when the handler [`accept`](BroadcastRequest::accept)s it (or errors if it + /// [`reject`](BroadcastRequest::reject)s or every handler drops). Concurrent requests for + /// the same unannounced path coalesce onto one handler request. + /// + /// The returned future resolves to [`Error::Unroutable`] when the path is not announced and no + /// dynamic handler exists, or [`Error::Dropped`] once the origin is gone. A request that is + /// registered while a handler is live but then loses every handler before being served also + /// resolves to [`Error::Unroutable`]. Unlike an announced broadcast, a dynamically served one + /// is never visible to [`Self::announced`]. + pub fn request_broadcast(&self, path: impl AsPath) -> kio::Pending { + let path = path.as_path(); + + // Prefer a live announcement when one is present; the dynamic queue is only a fallback. + if let Some(broadcast) = self.get_broadcast(&path) { + return kio::Pending::new(BroadcastRequested::ready(broadcast)); + } + + // Key requests by absolute path so a scoped/rooted consumer and the handler + // (which may have a different root) agree on the same entry. + let absolute = self.root.join(&path).to_owned(); + + let Ok(mut state) = self.dynamic.write() else { + return kio::Pending::new(BroadcastRequested::failed(Error::Dropped)); + }; + + // Coalesce onto a queued request for the same path; otherwise register a new one. + let consumer = if let Some(producer) = state.requests.get(&absolute) { + producer.consume() + } else { + if state.dynamic == 0 { + return kio::Pending::new(BroadcastRequested::failed(Error::Unroutable)); + } + + let producer = kio::Producer::::default(); + let consumer = producer.consume(); + state.requests.insert(absolute.clone(), producer); + state.request_order.push_back(absolute); + consumer + }; + + kio::Pending::new(BroadcastRequested::pending(consumer)) + } + /// Returns the prefix that is automatically stripped from all paths. pub fn root(&self) -> &Path<'_> { &self.root @@ -1007,7 +1083,253 @@ impl Drop for OriginConsumer { impl Clone for OriginConsumer { fn clone(&self) -> Self { - OriginConsumer::new(self.info, self.root.clone(), self.nodes.clone()) + OriginConsumer::new(self.info, self.root.clone(), self.nodes.clone(), self.dynamic.clone()) + } +} + +/// Shared fallback request queue for an origin. +/// +/// Lives off to the side of the announce tree because dynamically served broadcasts +/// are never announced. Mirrors the `dynamic`/`requests`/`request_order` fields of the +/// broadcast and track models. +#[derive(Default)] +struct OriginDynamicState { + // Result channels for queued requests, keyed by absolute path. Concurrent + // `request_broadcast` calls for the same path coalesce onto the same channel while + // it is queued. The producer is moved out (and the entry removed) when the handler + // picks the request up via [`OriginDynamic::requested_broadcast`]. + requests: HashMap>, + + // Requested paths in FIFO order for the handler to drain. + request_order: VecDeque, + + // The number of live `OriginDynamic` handlers. While zero, `request_broadcast` + // fails fast with `Unroutable` rather than queueing a request nobody will serve. + dynamic: usize, +} + +impl OriginDynamicState { + /// Drop every queued request, closing its result channel so awaiting requesters + /// resolve to an error. Called when the last handler goes away. + fn reject_requests(&mut self) { + self.requests.clear(); + self.request_order.clear(); + } +} + +/// One-shot result of a dynamic broadcast request. +/// +/// Stays `None` until a handler [`accept`](BroadcastRequest::accept)s (yielding the served +/// broadcast) or [`reject`](BroadcastRequest::reject)s (yielding an error). The producer is +/// dropped right after writing, closing the channel; kio checks the value before the closed +/// flag, so an awaiting requester still observes the final result. +#[derive(Default)] +struct PendingBroadcast { + resolved: Option>, +} + +/// Picks up [`OriginConsumer::request_broadcast`] calls for paths that are not announced. +/// +/// The origin-level analogue of [`crate::BroadcastDynamic`]: where that serves tracks on +/// demand within a broadcast, this serves whole broadcasts on demand within an origin. A +/// relay uses it as a fallback router, fetching a broadcast from upstream only when a +/// downstream consumer asks for an exact path that nobody announced. +/// +/// Served broadcasts are deliberately *not* announced, so they never appear in +/// [`OriginConsumer::announced`]. Drop this handle (and every clone) to reject the +/// requests still waiting to be served. +pub struct OriginDynamic { + info: Origin, + root: PathOwned, + state: kio::Producer, +} + +impl Clone for OriginDynamic { + fn clone(&self) -> Self { + // Mirror `new`: bump `dynamic` so each live handle is counted. Without this, + // dropping a clone would decrement past `new`'s increment and prematurely flip + // `dynamic` to zero, making future `request_broadcast` calls return `Unroutable`. + if let Ok(mut state) = self.state.write() { + state.dynamic += 1; + } + + Self { + info: self.info, + root: self.root.clone(), + state: self.state.clone(), + } + } +} + +impl OriginDynamic { + fn new(info: Origin, root: PathOwned, state: kio::Producer) -> Self { + if let Ok(mut state) = state.write() { + state.dynamic += 1; + } + + Self { info, root, state } + } + + /// The origin this handler belongs to. + pub fn info(&self) -> &Origin { + &self.info + } + + // Gate readiness on a queued request; mutate through the returned `Mut`. + fn poll(&self, waiter: &kio::Waiter, f: F) -> Poll, Error>> + where + F: FnMut(&kio::Ref<'_, OriginDynamicState>) -> Poll<()>, + { + Poll::Ready(match ready!(self.state.poll(waiter, f)) { + Ok(state) => Ok(state), + Err(_) => Err(Error::Dropped), + }) + } + + /// Poll for the next requested broadcast, without blocking. + pub fn poll_requested_broadcast(&mut self, waiter: &kio::Waiter) -> Poll> { + let mut state = ready!(self.poll(waiter, |state| { + if state.request_order.is_empty() { + Poll::Pending + } else { + Poll::Ready(()) + } + }))?; + + let path = state.request_order.pop_front().expect("predicate guaranteed a request"); + let producer = state.requests.remove(&path).expect("request_order out of sync"); + Poll::Ready(Ok(BroadcastRequest { path, producer })) + } + + /// Block until a consumer requests an unannounced broadcast, returning a + /// [`BroadcastRequest`] to serve. + pub async fn requested_broadcast(&mut self) -> Result { + kio::wait(|waiter| self.poll_requested_broadcast(waiter)).await + } + + /// Returns the prefix that is automatically stripped from requested paths. + pub fn root(&self) -> &Path<'_> { + &self.root + } +} + +impl Drop for OriginDynamic { + fn drop(&mut self) { + if let Ok(mut state) = self.state.write() { + // Saturating sub so `OriginProducer::dynamic` can stay infallible. + state.dynamic = state.dynamic.saturating_sub(1); + if state.dynamic == 0 { + // No handlers left to fulfill queued requests; close them. + state.reject_requests(); + } + } + } +} + +/// A pending request for a broadcast that was not announced. +/// +/// Yielded by [`OriginDynamic::requested_broadcast`]. The requester is awaiting inside +/// [`OriginConsumer::request_broadcast`]; [`accept`](Self::accept) resolves it with a live +/// broadcast (which the handler keeps producing into) and [`reject`](Self::reject) resolves +/// it with an error. Dropping the request without either rejects it. +pub struct BroadcastRequest { + // Absolute path that was requested. + path: PathOwned, + + // Result channel back to the awaiting requester(s). Writing `resolved` and dropping + // this wakes them with the outcome. + producer: kio::Producer, +} + +impl BroadcastRequest { + /// The absolute path that was requested. + pub fn path(&self) -> &Path<'_> { + &self.path + } + + /// Accept the request, resolving every awaiting requester with `broadcast`. + /// + /// The caller keeps producing into `broadcast` (e.g. a relay proxying tracks from + /// upstream); the requesters receive a consumer for it. The broadcast is *not* + /// announced. + pub fn accept(self, broadcast: BroadcastConsumer) { + if let Ok(mut state) = self.producer.write() { + state.resolved = Some(Ok(broadcast)); + } + // `self.producer` drops here, closing the channel; the value is still observable. + } + + /// Reject the request, resolving every awaiting requester with `err`. + pub fn reject(self, err: Error) { + if let Ok(mut state) = self.producer.write() { + state.resolved = Some(Err(err)); + } + } +} + +/// The pollable result of [`OriginConsumer::request_broadcast`]. +/// +/// Awaited via the [`kio::Pending`] wrapper; resolves to the [`BroadcastConsumer`] +/// immediately when the broadcast was already announced, or once an [`OriginDynamic`] +/// handler serves the request. Resolves to an error if the request is rejected or every +/// handler drops before serving it. +pub struct BroadcastRequested { + inner: Requested, +} + +enum Requested { + // Already announced: resolves immediately with a clone of this broadcast. + Ready(BroadcastConsumer), + // Unroutable at request time, or the origin was already dropped: resolves immediately + // with this error. Baked in so `request_broadcast` itself stays infallible. + Failed(Error), + // Awaiting a handler: resolves when the request's result channel is written. + Pending(kio::Consumer), +} + +impl BroadcastRequested { + fn ready(broadcast: BroadcastConsumer) -> Self { + Self { + inner: Requested::Ready(broadcast), + } + } + + fn failed(error: Error) -> Self { + Self { + inner: Requested::Failed(error), + } + } + + fn pending(consumer: kio::Consumer) -> Self { + Self { + inner: Requested::Pending(consumer), + } + } + + /// Poll for the requested broadcast without blocking. + pub fn poll_ok(&self, waiter: &kio::Waiter) -> Poll> { + match &self.inner { + Requested::Ready(broadcast) => Poll::Ready(Ok(broadcast.clone())), + Requested::Failed(error) => Poll::Ready(Err(error.clone())), + Requested::Pending(consumer) => Poll::Ready( + match ready!(consumer.poll(waiter, |state| match &state.resolved { + Some(result) => Poll::Ready(result.clone()), + None => Poll::Pending, + })) { + Ok(result) => result, + // Every handler dropped without resolving: nobody could route it. + Err(_closed) => Err(Error::Unroutable), + }, + ), + } + } +} + +impl kio::Future for BroadcastRequested { + type Output = Result; + + fn poll(&self, waiter: &kio::Waiter) -> Poll { + self.poll_ok(waiter) } } @@ -1055,6 +1377,8 @@ impl OriginConsumer { #[cfg(test)] mod tests { + use futures::FutureExt; + use crate::Broadcast; use super::*; @@ -2214,4 +2538,164 @@ mod tests { "unexpected path in pending updates", ); } + + // With no OriginDynamic handler, an unannounced path resolves to Unroutable. + #[tokio::test] + async fn dynamic_request_unroutable_without_handler() { + let origin = Origin::random().produce(); + let consumer = origin.consume(); + assert!(matches!( + consumer.request_broadcast("missing").await, + Err(Error::Unroutable) + )); + } + + // A dynamically served broadcast resolves the requester, but is never announced. + #[tokio::test(start_paused = true)] + async fn dynamic_request_served_not_announced() { + let origin = Origin::random().produce(); + let mut dynamic = origin.dynamic(); + let consumer = origin.consume(); + + // A separate announce cursor must never observe the dynamic broadcast. + let mut announced = origin.consume(); + announced.assert_next_wait(); + + // Request a path that nobody announced; the future stays pending until served. + // Registration happens up front, so the handler sees the request immediately. + let request_fut = consumer.request_broadcast("fallback"); + + // The handler serves it with a live broadcast it keeps producing into. + let served = Broadcast::new().produce(); + + let request = dynamic.requested_broadcast().await.unwrap(); + assert_eq!(request.path(), &Path::new("fallback")); + request.accept(served.consume()); + + let broadcast = request_fut.await.unwrap(); + assert!(broadcast.is_clone(&served.consume())); + + // Still nothing announced. + announced.assert_next_wait(); + } + + // Concurrent requests for the same queued path coalesce onto one handler request. + #[tokio::test(start_paused = true)] + async fn dynamic_request_coalesces() { + let origin = Origin::random().produce(); + let mut dynamic = origin.dynamic(); + let consumer = origin.consume(); + + // Both register before the handler drains either. + let f1 = consumer.request_broadcast("dup"); + let f2 = consumer.request_broadcast("dup"); + + // Exactly one request reaches the handler. + let request = dynamic.requested_broadcast().await.unwrap(); + assert_eq!(request.path(), &Path::new("dup")); + assert!( + dynamic.requested_broadcast().now_or_never().is_none(), + "a coalesced request must not be served twice" + ); + + // Accepting resolves both awaiting requesters with the same broadcast. + let served = Broadcast::new().produce(); + request.accept(served.consume()); + assert!(f1.await.unwrap().is_clone(&served.consume())); + assert!(f2.await.unwrap().is_clone(&served.consume())); + } + + // Rejecting a request resolves the requester with the error. + #[tokio::test(start_paused = true)] + async fn dynamic_request_rejected() { + let origin = Origin::random().produce(); + let mut dynamic = origin.dynamic(); + let consumer = origin.consume(); + + let request_fut = consumer.request_broadcast("fallback"); + + let request = dynamic.requested_broadcast().await.unwrap(); + request.reject(Error::Cancel); + + assert!(matches!(request_fut.await, Err(Error::Cancel))); + } + + // Dropping the last handler resolves queued requests with an error and reverts to + // resolving Unroutable. + #[tokio::test(start_paused = true)] + async fn dynamic_request_handler_dropped() { + let origin = Origin::random().produce(); + let dynamic = origin.dynamic(); + let consumer = origin.consume(); + + let request_fut = consumer.request_broadcast("fallback"); + drop(dynamic); + assert!(matches!(request_fut.await, Err(Error::Unroutable))); + + // With no handler left, a fresh request resolves Unroutable. + assert!(matches!( + consumer.request_broadcast("again").await, + Err(Error::Unroutable) + )); + } + + // `accept` is decoupled from the dynamic count: once a handler has picked a request up, + // it can still serve it even if every handler (including itself) drops first, flipping the + // count to zero. The in-flight request must not be rejected as `Unroutable`. + #[tokio::test(start_paused = true)] + async fn dynamic_request_accept_after_handler_dropped() { + let origin = Origin::random().produce(); + let mut dynamic = origin.dynamic(); + let consumer = origin.consume(); + + let request_fut = consumer.request_broadcast("fallback"); + + // The handler picks the request up, then every handler drops (count -> 0). + let request = dynamic.requested_broadcast().await.unwrap(); + drop(dynamic); + + // Accept still resolves the awaiting requester with the served broadcast. + let served = Broadcast::new().produce(); + request.accept(served.consume()); + assert!(request_fut.await.unwrap().is_clone(&served.consume())); + } + + // A live announcement wins over the dynamic fallback; no request is queued. + #[tokio::test(start_paused = true)] + async fn dynamic_request_prefers_announced() { + let origin = Origin::random().produce(); + let mut dynamic = origin.dynamic(); + let consumer = origin.consume(); + + let broadcast = Broadcast::new().produce(); + assert!(origin.publish_broadcast("live", broadcast.consume())); + + let got = consumer.request_broadcast("live").await.unwrap(); + assert!( + got.is_clone(&broadcast.consume()), + "should return the announced broadcast" + ); + assert!( + dynamic.requested_broadcast().now_or_never().is_none(), + "an announced path must not queue a fallback request" + ); + } + + // Cloning a handler and dropping the clone must not flip the count to zero. + #[tokio::test(start_paused = true)] + async fn dynamic_clone_keeps_alive() { + let origin = Origin::random().produce(); + let dynamic = origin.dynamic(); + let consumer = origin.consume(); + + drop(dynamic.clone()); + + // The original handle is still live, so the request registers (stays pending) + // instead of resolving Unroutable. + let request_fut = consumer.request_broadcast("fallback"); + assert!( + request_fut.now_or_never().is_none(), + "request should stay pending until served" + ); + } } From 4f2ce12eb4a335738c02e8d247d9fc1a2238638d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Jun 2026 07:08:12 -0700 Subject: [PATCH 10/34] feat(moq-rtmp): RTMP/E-RTMP gateway + enhanced-RTMP FLV codecs on main (#1914) Co-authored-by: Claude Opus 4.8 --- Cargo.lock | 106 +- Cargo.toml | 2 + doc/.vitepress/config.ts | 1 + doc/bin/index.md | 6 + doc/bin/rtmp.md | 134 ++ rs/moq-mux/src/codec/av1/mod.rs | 31 + rs/moq-mux/src/codec/h265/mod.rs | 56 + rs/moq-mux/src/codec/vp9/mod.rs | 18 + rs/moq-mux/src/container/flv/export.rs | 242 +++- rs/moq-mux/src/container/flv/export_test.rs | 92 ++ rs/moq-mux/src/container/flv/import.rs | 332 +++-- rs/moq-mux/src/container/flv/import_test.rs | 82 ++ rs/moq-mux/src/container/flv/mod.rs | 46 +- rs/moq-native/src/tls.rs | 45 + rs/moq-rtmp/Cargo.toml | 72 ++ rs/moq-rtmp/README.md | 183 +++ rs/moq-rtmp/bin/moq-rtmp.rs | 223 ++++ rs/moq-rtmp/bin/serve.rs | 38 + rs/moq-rtmp/bin/web.rs | 64 + rs/moq-rtmp/src/error.rs | 22 + rs/moq-rtmp/src/flv.rs | 249 ++++ rs/moq-rtmp/src/lib.rs | 67 + rs/moq-rtmp/src/listen.rs | 244 ++++ rs/moq-rtmp/src/server.rs | 1268 +++++++++++++++++++ 24 files changed, 3488 insertions(+), 135 deletions(-) create mode 100644 doc/bin/rtmp.md create mode 100644 rs/moq-rtmp/Cargo.toml create mode 100644 rs/moq-rtmp/README.md create mode 100644 rs/moq-rtmp/bin/moq-rtmp.rs create mode 100644 rs/moq-rtmp/bin/serve.rs create mode 100644 rs/moq-rtmp/bin/web.rs create mode 100644 rs/moq-rtmp/src/error.rs create mode 100644 rs/moq-rtmp/src/flv.rs create mode 100644 rs/moq-rtmp/src/lib.rs create mode 100644 rs/moq-rtmp/src/listen.rs create mode 100644 rs/moq-rtmp/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 93c8aeaa9..683a7f86c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,6 +608,15 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1339,6 +1348,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "crypto-mac" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1643,6 +1662,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" @@ -2743,7 +2771,17 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" +dependencies = [ + "crypto-mac", + "digest 0.9.0", ] [[package]] @@ -4131,6 +4169,31 @@ dependencies = [ "wiremock", ] +[[package]] +name = "moq-rtmp" +version = "0.0.1" +dependencies = [ + "anyhow", + "axum", + "axum-server", + "bytes", + "clap", + "futures", + "moq-mux", + "moq-native", + "moq-net", + "rml_rtmp", + "rustls", + "sd-notify", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tower-http", + "tracing", + "url", +] + [[package]] name = "moq-token" version = "0.6.0" @@ -5783,6 +5846,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -6074,7 +6138,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -6092,6 +6156,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rml_amf0" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63551cfcd4d1f42733c190e4b58dd268b1eacb73410d9afbf62784aa12cac240" +dependencies = [ + "byteorder", + "thiserror 1.0.69", +] + +[[package]] +name = "rml_rtmp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a354e80eb7aa2a6fed09b3bd25c19bcfd32cf51f81f1219f4ec04f34519989da" +dependencies = [ + "byteorder", + "bytes", + "hmac 0.10.1", + "rand 0.8.6", + "rml_amf0", + "sha2 0.9.9", + "thiserror 1.0.69", +] + [[package]] name = "rsa" version = "0.9.10" @@ -6670,6 +6759,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index 856b72993..f32450372 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "rs/moq-native", "rs/moq-net", "rs/moq-relay", + "rs/moq-rtmp", "rs/moq-token", "rs/moq-token-cli", "rs/moq-video", @@ -39,6 +40,7 @@ default-members = [ "rs/moq-mux", "rs/moq-native", "rs/moq-relay", + "rs/moq-rtmp", "rs/moq-token", "rs/moq-token-cli", "rs/moq-video", diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index cac2c8451..3e73bc619 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -137,6 +137,7 @@ export default defineConfig({ ], }, { text: "CLI", link: "/bin/cli" }, + { text: "RTMP", link: "/bin/rtmp" }, { text: "OBS", link: "/bin/obs" }, { text: "GStreamer", link: "/bin/gstreamer" }, { text: "Web", link: "/bin/web" }, diff --git a/doc/bin/index.md b/doc/bin/index.md index 233b9c151..17ea3759f 100644 --- a/doc/bin/index.md +++ b/doc/bin/index.md @@ -29,6 +29,12 @@ Another tool does the encoding (ex. ffmpeg), making it easy to pipe any media in ffmpeg -f avfoundation -i "0" -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` +## [moq-rtmp](/bin/rtmp) + +An RTMP / enhanced-RTMP -> MoQ ingest gateway. Accepts RTMP from any encoder +(OBS, ffmpeg) and publishes it into MoQ, supporting H.264/HEVC/AV1/VP9 and +AAC/Opus/AC-3. + ## [OBS Plugin](/bin/obs) Real-time latency with the familiar OBS interface. diff --git a/doc/bin/rtmp.md b/doc/bin/rtmp.md new file mode 100644 index 000000000..30f7a7634 --- /dev/null +++ b/doc/bin/rtmp.md @@ -0,0 +1,134 @@ +--- +title: moq-rtmp +description: RTMP / enhanced-RTMP <-> MoQ gateway (ingest and egress) +--- + +# moq-rtmp + +`moq-rtmp` bridges [RTMP](https://en.wikipedia.org/wiki/Real-Time_Messaging_Protocol) +(the protocol OBS, ffmpeg, and most hardware encoders and players speak) and +Media over QUIC, in **both directions**: + +- **Publish (ingest):** an encoder pushes a stream in, and `moq-rtmp` publishes it + into MoQ as an ordinary broadcast. +- **Play (egress):** a player pulls `rtmp://host//`, and `moq-rtmp` + subscribes to that broadcast from MoQ and streams it back down. VLC, ffplay, and + mpv can play it (browsers can't -- Flash is dead). + +RTMP carries media as FLV-format audio/video messages. `moq-rtmp` runs the RTMP +handshake and chunk/AMF session (via the pure-Rust +[`rml_rtmp`](https://crates.io/crates/rml_rtmp), no librtmp). On ingest it re-wraps +each message as an FLV tag and feeds it to `moq-mux`'s FLV demuxer; on egress it +muxes the broadcast back to FLV with `moq-mux` and sends the tags out as RTMP +messages. It's the sibling of `moq-srt` (SRT/MPEG-TS) and `moq-rtc` (WHIP/WHEP). + +Both **legacy RTMP** (H.264 + AAC) and **enhanced RTMP** (E-RTMP: the HEVC, AV1, +VP9, Opus, and AC-3 FourCC payloads) are supported in each direction, because all +codec handling lives in the `moq-mux` FLV demuxer/muxer. Legacy players that speak +only H.264 + AAC will reject the E-RTMP codecs on the play path. + +## CLI shape + +The binary has two modes, mirroring `moq-srt`: + +```bash +# serve: ingest RTMP and serve it directly as a local relay +moq-rtmp serve --server-bind [::]:443 --tls-generate localhost \ + --rtmp-listen 0.0.0.0:1935 --rtmp-prefix live/ + +# publish: ingest RTMP and forward broadcasts to a remote relay +moq-rtmp publish --relay https://relay.example.com \ + --rtmp-listen 0.0.0.0:1935 --rtmp-prefix live/ +``` + +Point any encoder at it. In OBS, set the server to `rtmp://127.0.0.1:1935/live` +and the stream key to `cam0`; with ffmpeg: + +```bash +# Lands at broadcast `live/cam0`. +ffmpeg -re -i input.mp4 -c copy -f flv rtmp://127.0.0.1:1935/live/cam0 +``` + +Then play the same broadcast back out over RTMP from any player: + +```bash +# Pulls broadcast `live/cam0` (the same URL it was pushed to). +ffplay rtmp://127.0.0.1:1935/live/cam0 +mpv rtmp://127.0.0.1:1935/live/cam0 +vlc rtmp://127.0.0.1:1935/live/cam0 +``` + +### `serve` flags + +- `--server-bind`: QUIC/WebTransport bind address (default `[::]:443`). Also + serves the `/certificate.sha256` endpoint browsers need for self-signed + `http://` origins, and a static player directory with `--dir`. +- `--tls-generate ` / `--tls-cert` / `--tls-key`: server TLS. + +### `publish` flags + +- `--relay`: upstream MoQ relay to publish every ingested broadcast into. + +### RTMP flags + +- `--rtmp-listen`: TCP bind address for the RTMP server (default `[::]:1935`). +- `--rtmp-prefix`: prepended to every broadcast path, to namespace a listener's + streams (e.g. `live/`). + +### RTMPS flags + +RTMPS (RTMP over TLS, `rtmps://`) is served on a second listener alongside +plaintext RTMP, sharing the same `--rtmp-prefix`: + +- `--rtmps-listen`: TCP bind address for the RTMPS server (off unless set). RTMPS + has no well-known port; 443 or a custom one are common. +- `--rtmps-tls-cert` / `--rtmps-tls-key`: PEM certificate chain and key. +- `--rtmps-tls-generate `: or generate a throwaway self-signed cert + (testing only; clients must disable verification). + +```bash +moq-rtmp serve --server-bind [::]:443 --tls-generate localhost \ + --rtmp-listen 0.0.0.0:1935 \ + --rtmps-listen 0.0.0.0:1936 --rtmps-tls-cert cert.pem --rtmps-tls-key key.pem \ + --rtmp-prefix live/ +``` + +## Routing + +Each connection's broadcast path is `/` from the RTMP app and stream +key (`rtmp://host//`), falling back to just the app when the key is +empty, with `--rtmp-prefix` prepended. The same routing applies to both +directions, so the URL round-trips: push to `rtmp://host/live/cam0`, then pull it +back from `rtmp://host/live/cam0`. + +A play waits for the broadcast to be announced, so a player can connect slightly +before the publisher. First **publisher** on a path wins (a second publish to a +live path is rejected); **plays** don't claim a path, so any number of players can +pull the same broadcast at once. In `serve` mode plays are served from the same +origin the server exposes, so anything in it -- RTMP ingests and otherwise -- can +be pulled back out over RTMP. + +## Notes and limitations + +- **Auth.** The binary (and the `moq_rtmp::run` convenience) is unauthenticated: + anyone who can reach the TCP port can publish or play. Gate it with a host + firewall or a private network. To authenticate, embed the library and drive its + `Server` / `Request` API: `Server::accept` yields a `Request` that is either a + `Publish` or a `Play`, and you verify the app / stream key (e.g. the stream key + as a moq-token JWT) before accepting it into / out of an origin at a path of your + choosing, or rejecting it -- no callback, the policy lives in your loop. +- **Embedding.** A relay can run the gateway in-process by depending on the + `moq-rtmp` library (`default-features = false`). Call `moq_rtmp::run` against its + own origin for the unauthenticated case (publishers ingest into it, players are + served out of it), or use `Server` / `Request` to plug in the relay's existing + JWT/path auth and scope the origin per token. Either way the media stays local + with no extra hop. +- **RTMPS.** Embedders can terminate TLS themselves: set `Config::tls` (or + `Server::with_tls`) with a `rustls::ServerConfig`, or accept the connection and + finish the TLS handshake by hand and hand the stream to `moq_rtmp::accept_stream` + (which works over any `AsyncRead + AsyncWrite` transport). +- **Codecs.** FLAC and MP3 enhanced-audio payloads are dropped (no MoQ catalog + codec); everything else (H.264/HEVC/AV1/VP9 video, AAC/Opus/AC-3/E-AC-3 audio) + is supported. + +(Written by Claude) diff --git a/rs/moq-mux/src/codec/av1/mod.rs b/rs/moq-mux/src/codec/av1/mod.rs index a1f53f5bf..d67c5b6a5 100644 --- a/rs/moq-mux/src/codec/av1/mod.rs +++ b/rs/moq-mux/src/codec/av1/mod.rs @@ -10,6 +10,37 @@ pub use import::*; use hang::catalog::AV1; +/// Build a catalog [`VideoConfig`](hang::catalog::VideoConfig) for the `av01` +/// shape from an AV1CodecConfigurationRecord (av1C). +/// +/// Used by the enhanced-RTMP / FLV importer, where the av1C arrives out of band +/// in the sequence-header tag (leading `0x81` marker) and the coded samples are +/// raw OBU temporal units, so the record passes straight through as the catalog +/// `description`. Resolution and color live in the inline sequence header, not +/// the av1C, so `coded_width`/`coded_height` are left unset here. +pub(crate) fn config_from_av1c(av1c: &[u8]) -> anyhow::Result { + // av1C: byte 0 = marker(1)|version(7) = 0x81, byte 1 = seq_profile(3)|seq_level_idx_0(5), + // byte 2 = seq_tier_0|high_bitdepth|twelve_bit|monochrome|subsampling_x|subsampling_y|sample_position(2). + anyhow::ensure!(av1c.len() >= 4 && av1c[0] == 0x81, "invalid av1C record"); + let high_bitdepth = ((av1c[2] >> 6) & 0x01) == 1; + let twelve_bit = ((av1c[2] >> 5) & 0x01) == 1; + + let mut config = hang::catalog::VideoConfig::new(AV1 { + profile: (av1c[1] >> 5) & 0x07, + level: av1c[1] & 0x1f, + tier: if ((av1c[2] >> 7) & 0x01) == 1 { 'H' } else { 'M' }, + bitdepth: bitdepth(twelve_bit, high_bitdepth), + mono_chrome: ((av1c[2] >> 4) & 0x01) == 1, + chroma_subsampling_x: ((av1c[2] >> 3) & 0x01) == 1, + chroma_subsampling_y: ((av1c[2] >> 2) & 0x01) == 1, + chroma_sample_position: av1c[2] & 0x03, + ..Default::default() + }); + config.description = Some(bytes::Bytes::copy_from_slice(av1c)); + config.container = hang::catalog::Container::Legacy; + Ok(config) +} + /// Map a parsed `mp4_atom::Av1c` (AV1CodecConfigurationRecord) to the /// hang catalog's AV1 codec struct. /// diff --git a/rs/moq-mux/src/codec/h265/mod.rs b/rs/moq-mux/src/codec/h265/mod.rs index 39850a142..5afebf188 100644 --- a/rs/moq-mux/src/codec/h265/mod.rs +++ b/rs/moq-mux/src/codec/h265/mod.rs @@ -158,6 +158,62 @@ fn process_nal( } } +/// Build a catalog [`VideoConfig`](hang::catalog::VideoConfig) for the `hvc1` +/// shape from an HEVCDecoderConfigurationRecord (hvcC). +/// +/// Used by the enhanced-RTMP / FLV importer: the hvcC arrives out of band in the +/// sequence-header tag and the coded samples are length-prefixed NALU, so the +/// record passes straight through as the catalog `description` (`in_band: false`). +pub(crate) fn config_from_hvcc(hvcc: &[u8]) -> anyhow::Result { + let sps_nal = hvcc_sps(hvcc)?; + let sps = SpsNALUnit::parse(&mut &sps_nal[..]).context("failed to parse SPS NAL unit for hvcC")?; + let profile = &sps.rbsp.profile_tier_level.general_profile; + + let mut config = hang::catalog::VideoConfig::new(hang::catalog::H265 { + in_band: false, + profile_space: profile.profile_space, + profile_idc: profile.profile_idc, + profile_compatibility_flags: profile.profile_compatibility_flag.bits().to_be_bytes(), + tier_flag: profile.tier_flag, + level_idc: profile.level_idc.context("missing level_idc in SPS")?, + constraint_flags: pack_constraint_flags(profile), + }); + config.coded_width = Some(sps.rbsp.cropped_width() as u32); + config.coded_height = Some(sps.rbsp.cropped_height() as u32); + config.description = Some(Bytes::copy_from_slice(hvcc)); + config.container = hang::catalog::Container::Legacy; + Ok(config) +} + +/// Extract the first SPS NAL from an HEVCDecoderConfigurationRecord, walking the +/// typed NAL arrays (unlike [`hvcc_params`], which flattens them). +fn hvcc_sps(hvcc: &[u8]) -> anyhow::Result { + anyhow::ensure!(hvcc.len() >= 23, "HEVCDecoderConfigurationRecord too short"); + let num_arrays = hvcc[22]; + let sps_nut = u8::from(NALUnitType::SpsNut); + + let mut pos = 23; + for _ in 0..num_arrays { + anyhow::ensure!(hvcc.len() >= pos + 3, "truncated hvcC NAL array header"); + let nal_type = hvcc[pos] & 0x3f; + pos += 1; + let num_nalus = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]); + pos += 2; + for i in 0..num_nalus { + anyhow::ensure!(hvcc.len() >= pos + 2, "truncated hvcC NAL length"); + let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; + pos += 2; + anyhow::ensure!(hvcc.len() >= pos + len, "hvcC NAL exceeds buffer"); + if nal_type == sps_nut && i == 0 { + return Ok(Bytes::copy_from_slice(&hvcc[pos..pos + len])); + } + pos += len; + } + } + + anyhow::bail!("hvcC has no SPS") +} + /// Build an HEVCDecoderConfigurationRecord (ISO/IEC 14496-15 §8.3.3). /// Single-layer streams only. Each NAL array (VPS, SPS, PPS) carries every /// distinct parameter set the stream defined, in arrival order; the profile/tier diff --git a/rs/moq-mux/src/codec/vp9/mod.rs b/rs/moq-mux/src/codec/vp9/mod.rs index 6a0c8efd0..816358abf 100644 --- a/rs/moq-mux/src/codec/vp9/mod.rs +++ b/rs/moq-mux/src/codec/vp9/mod.rs @@ -148,6 +148,24 @@ impl KeyFrame { } } +/// Build a catalog [`VideoConfig`](hang::catalog::VideoConfig) from a VP9 key +/// frame's uncompressed header, or `None` if `data` is not a key frame. +/// +/// VP9 has no out-of-band config record (the FLV `vp09` shape configures the +/// decoder in band), so the enhanced-RTMP importer derives the config from each +/// key frame instead of a sequence-header tag. +pub(crate) fn config_from_keyframe(data: &[u8]) -> anyhow::Result> { + let Some(key) = FrameHeader::parse(data)?.key else { + return Ok(None); + }; + let (width, height) = (key.width, key.height); + let mut config = hang::catalog::VideoConfig::new(key.to_catalog()); + config.coded_width = Some(width as u32); + config.coded_height = Some(height as u32); + config.container = hang::catalog::Container::Legacy; + Ok(Some(config)) +} + /// `vpcC` chroma subsampling enum from the VP9 `subsampling_x`/`subsampling_y` /// flags. VP9 doesn't signal chroma siting, so 4:2:0 maps to "colocated" (1), /// the value encoders conventionally write. diff --git a/rs/moq-mux/src/container/flv/export.rs b/rs/moq-mux/src/container/flv/export.rs index 1819e2502..0cd2da443 100644 --- a/rs/moq-mux/src/container/flv/export.rs +++ b/rs/moq-mux/src/container/flv/export.rs @@ -1,28 +1,62 @@ //! FLV muxer. //! //! [`Export`] subscribes to a MoQ broadcast and produces a single FLV byte -//! stream: the file header, an AVC and/or AAC sequence header, then one tag per -//! media frame interleaved by timestamp. Frames flow through [`ExportSource`], -//! which normalizes H.264 to length-prefixed NALU plus a resolved avcC (parsing -//! inline avc3 parameter sets when needed) and hands audio through with its -//! `AudioSpecificConfig`. FLV carries a single video and a single audio stream, -//! so only the first rendition of each kind is muxed; extra renditions and any -//! non-AVC/AAC codec are rejected. +//! stream: the file header, the video/audio sequence headers, then one tag per +//! media frame interleaved by timestamp. Legacy H.264 + AAC are muxed as the +//! classic CodecID tags; HEVC, AV1, VP9, Opus, AC-3, and E-AC-3 are muxed as the +//! enhanced-RTMP (E-RTMP) FourCC payloads. Frames flow through [`ExportSource`], +//! which normalizes H.264/H.265 to length-prefixed NALU plus a resolved +//! avcC/hvcC (parsing inline avc3/hev1 parameter sets when needed) and hands the +//! other codecs through unchanged. FLV carries a single video and a single audio +//! stream, so only the first rendition of each kind is muxed; extra renditions +//! and any unsupported codec are rejected. use std::task::Poll; use std::time::Duration; use anyhow::Context; use bytes::{BufMut, Bytes, BytesMut}; -use hang::catalog::{AudioCodec, Catalog, Container, VideoCodec}; +use hang::catalog::{AV1, AudioCodec, Catalog, Container, VideoCodec}; use super::{ - AAC_AUDIO_TAG_HEADER, AAC_RAW, AAC_SEQUENCE_HEADER, AVC_NALU, AVC_SEQUENCE_HEADER, FRAME_TYPE_INTER, - FRAME_TYPE_KEY, TAG_AUDIO, TAG_HEADER_LEN, TAG_VIDEO, VIDEO_CODEC_AVC, + AAC_AUDIO_TAG_HEADER, AAC_RAW, AAC_SEQUENCE_HEADER, AUDIO_FORMAT_EX, AUDIO_PACKET_CODED_FRAMES, + AUDIO_PACKET_SEQUENCE_START, AVC_NALU, AVC_SEQUENCE_HEADER, FRAME_TYPE_INTER, FRAME_TYPE_KEY, TAG_AUDIO, + TAG_HEADER_LEN, TAG_VIDEO, VIDEO_CODEC_AVC, VIDEO_EX_HEADER, VIDEO_PACKET_CODED_FRAMES, + VIDEO_PACKET_SEQUENCE_START, }; use crate::catalog::CatalogFormat; use crate::container::{CatalogSource, ExportSource, Frame}; +/// Which FLV payload shape a bound track is muxed as: a legacy CodecID +/// (`Avc`/`Aac`) or an enhanced-RTMP FourCC codec. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Flavor { + Avc, + Hevc, + Av1, + Vp9, + Aac, + Opus, + Ac3, + Eac3, +} + +impl Flavor { + /// The enhanced-RTMP FourCC for this codec, or `None` for the legacy + /// (CodecID-signaled) AVC and AAC shapes. + fn fourcc(self) -> Option<[u8; 4]> { + match self { + Flavor::Hevc => Some(*b"hvc1"), + Flavor::Av1 => Some(*b"av01"), + Flavor::Vp9 => Some(*b"vp09"), + Flavor::Opus => Some(*b"Opus"), + Flavor::Ac3 => Some(*b"ac-3"), + Flavor::Eac3 => Some(*b"ec-3"), + Flavor::Avc | Flavor::Aac => None, + } + } +} + /// Subscribe to a broadcast and produce an FLV byte stream. /// /// Use [`next`](Self::next) to pull byte chunks. The first chunk is the FLV file @@ -55,6 +89,13 @@ struct FlvTrack { source: ExportSource, pending: Option, finished: bool, + /// The FLV payload shape (legacy CodecID vs enhanced FourCC) to mux this + /// track as, fixed from its catalog codec when it's bound. + flavor: Flavor, + /// A codec config record synthesized at bind time for the sequence-header + /// tag, used when the catalog (and thus [`ExportSource::description`]) carries + /// none. Only AV1 needs this today (its av1C is optional in the catalog). + fallback_description: Option, } impl Export { @@ -188,14 +229,22 @@ impl Export { if self.video.is_none() && let Some((name, config)) = catalog.video.renditions.iter().next() { - ensure_supported_video(config)?; + let flavor = video_flavor(config)?; ensure_legacy(&config.container, "video", name)?; + // AV1's av1C is optional in the catalog; synthesize one from the codec + // struct so the enhanced SequenceStart tag always has a config record. + let fallback_description = match (&config.codec, config.description.as_ref()) { + (VideoCodec::AV1(av1), None) => Some(Bytes::copy_from_slice(&av1c_bytes(av1))), + _ => None, + }; let source = ExportSource::for_video(&self.broadcast, name, config, self.latency)?; self.video = Some(FlvTrack { name: name.clone(), source, pending: None, finished: false, + flavor, + fallback_description, }); } if catalog.video.renditions.len() > 1 { @@ -205,7 +254,7 @@ impl Export { if self.audio.is_none() && let Some((name, config)) = catalog.audio.renditions.iter().next() { - ensure_supported_audio(config)?; + let flavor = audio_flavor(config)?; ensure_legacy(&config.container, "audio", name)?; let source = ExportSource::for_audio(&self.broadcast, name, config, self.latency)?; self.audio = Some(FlvTrack { @@ -213,6 +262,8 @@ impl Export { source, pending: None, finished: false, + flavor, + fallback_description: None, }); } if catalog.audio.renditions.len() > 1 { @@ -264,24 +315,14 @@ impl Export { // PreviousTagSize0 is always zero. out.put_u32(0); - if let Some(track) = &self.video { - let avcc = track.source.description().context("H.264 track missing avcC")?; - let mut body = BytesMut::with_capacity(5 + avcc.len()); - body.put_u8((FRAME_TYPE_KEY << 4) | VIDEO_CODEC_AVC); - body.put_u8(AVC_SEQUENCE_HEADER); - body.put_slice(&[0, 0, 0]); // composition time - body.put_slice(avcc); + if let Some(track) = &self.video + && let Some(body) = video_sequence_header(track)? + { write_tag(&mut out, TAG_VIDEO, 0, &body)?; } - if let Some(track) = &self.audio { - let asc = track - .source - .description() - .context("AAC track missing AudioSpecificConfig")?; - let mut body = BytesMut::with_capacity(2 + asc.len()); - body.put_u8(AAC_AUDIO_TAG_HEADER); - body.put_u8(AAC_SEQUENCE_HEADER); - body.put_slice(asc); + if let Some(track) = &self.audio + && let Some(body) = audio_sequence_header(track)? + { write_tag(&mut out, TAG_AUDIO, 0, &body)?; } @@ -317,22 +358,45 @@ impl Export { let mut out = BytesMut::with_capacity(TAG_HEADER_LEN + frame.payload.len() + 8); if is_video { - let mut body = BytesMut::with_capacity(5 + frame.payload.len()); + let flavor = self.video.as_ref().expect("video frame without a video track").flavor; let frame_type = if frame.keyframe { FRAME_TYPE_KEY } else { FRAME_TYPE_INTER }; - body.put_u8((frame_type << 4) | VIDEO_CODEC_AVC); - body.put_u8(AVC_NALU); - // We carry PTS in the tag timestamp, so the composition offset is zero. - body.put_slice(&[0, 0, 0]); + let mut body = BytesMut::with_capacity(8 + frame.payload.len()); + match flavor.fourcc() { + // Legacy AVC: CodecID + AVCPacketType + composition time (PTS in the tag). + None => { + body.put_u8((frame_type << 4) | VIDEO_CODEC_AVC); + body.put_u8(AVC_NALU); + body.put_slice(&[0, 0, 0]); + } + // Enhanced FourCC CodedFrames. hvc1 keeps the 3-byte composition + // time (zero, since we carry PTS in the tag); av01/vp09 omit it. + Some(fourcc) => { + body.put_u8(VIDEO_EX_HEADER | (frame_type << 4) | VIDEO_PACKET_CODED_FRAMES); + body.put_slice(&fourcc); + if flavor == Flavor::Hevc { + body.put_slice(&[0, 0, 0]); + } + } + } body.put_slice(&frame.payload); write_tag(&mut out, TAG_VIDEO, timestamp_ms, &body)?; } else { - let mut body = BytesMut::with_capacity(2 + frame.payload.len()); - body.put_u8(AAC_AUDIO_TAG_HEADER); - body.put_u8(AAC_RAW); + let flavor = self.audio.as_ref().expect("audio frame without an audio track").flavor; + let mut body = BytesMut::with_capacity(5 + frame.payload.len()); + match flavor.fourcc() { + None => { + body.put_u8(AAC_AUDIO_TAG_HEADER); + body.put_u8(AAC_RAW); + } + Some(fourcc) => { + body.put_u8((AUDIO_FORMAT_EX << 4) | AUDIO_PACKET_CODED_FRAMES); + body.put_slice(&fourcc); + } + } body.put_slice(&frame.payload); write_tag(&mut out, TAG_AUDIO, timestamp_ms, &body)?; } @@ -368,16 +432,110 @@ fn ensure_legacy(container: &Container, kind: &str, name: &str) -> anyhow::Resul } } -fn ensure_supported_video(config: &hang::catalog::VideoConfig) -> anyhow::Result<()> { +fn video_flavor(config: &hang::catalog::VideoConfig) -> anyhow::Result { match &config.codec { - VideoCodec::H264(_) => Ok(()), - other => anyhow::bail!("FLV export only supports H.264 video, got {other:?}"), + VideoCodec::H264(_) => Ok(Flavor::Avc), + VideoCodec::H265(_) => Ok(Flavor::Hevc), + VideoCodec::AV1(_) => Ok(Flavor::Av1), + VideoCodec::VP9(_) => Ok(Flavor::Vp9), + other => anyhow::bail!("FLV export does not support video codec {other:?}"), } } -fn ensure_supported_audio(config: &hang::catalog::AudioConfig) -> anyhow::Result<()> { +fn audio_flavor(config: &hang::catalog::AudioConfig) -> anyhow::Result { match &config.codec { - AudioCodec::AAC(_) => Ok(()), - other => anyhow::bail!("FLV export only supports AAC audio, got {other:?}"), + AudioCodec::AAC(_) => Ok(Flavor::Aac), + AudioCodec::Opus => Ok(Flavor::Opus), + AudioCodec::Ac3 => Ok(Flavor::Ac3), + AudioCodec::Ec3 => Ok(Flavor::Eac3), + other => anyhow::bail!("FLV export does not support audio codec {other:?}"), + } +} + +/// Build the FLV video sequence-header tag body, or `None` for codecs that carry +/// their config in band (VP9), so no out-of-band record is emitted. +fn video_sequence_header(track: &FlvTrack) -> anyhow::Result> { + let mut body = BytesMut::new(); + match track.flavor { + Flavor::Avc => { + let avcc = track.source.description().context("H.264 track missing avcC")?; + body.put_u8((FRAME_TYPE_KEY << 4) | VIDEO_CODEC_AVC); + body.put_u8(AVC_SEQUENCE_HEADER); + body.put_slice(&[0, 0, 0]); // composition time + body.put_slice(avcc); + } + Flavor::Hevc => { + let hvcc = track.source.description().context("H.265 track missing hvcC")?; + ex_video_sequence_start(&mut body, b"hvc1", hvcc); + } + Flavor::Av1 => { + // av1C from the catalog `description`, or the record synthesized at bind + // time (the sequence header is carried in band, so an empty configOBUs + // record is enough for the decoder). + let av1c = track + .source + .description() + .or(track.fallback_description.as_ref()) + .context("AV1 track missing av1C")?; + ex_video_sequence_start(&mut body, b"av01", av1c); + } + // VP9 configures the decoder from the key frame; no sequence header tag. + Flavor::Vp9 => return Ok(None), + Flavor::Aac | Flavor::Opus | Flavor::Ac3 | Flavor::Eac3 => unreachable!("audio flavor on a video track"), } + Ok(Some(body)) +} + +/// Build the FLV audio sequence-header tag body, or `None` for codecs that carry +/// their config in band (AC-3 / E-AC-3). +fn audio_sequence_header(track: &FlvTrack) -> anyhow::Result> { + let mut body = BytesMut::new(); + match track.flavor { + Flavor::Aac => { + let asc = track + .source + .description() + .context("AAC track missing AudioSpecificConfig")?; + body.put_u8(AAC_AUDIO_TAG_HEADER); + body.put_u8(AAC_SEQUENCE_HEADER); + body.put_slice(asc); + } + Flavor::Opus => { + let head = track.source.description().context("Opus track missing OpusHead")?; + body.put_u8((AUDIO_FORMAT_EX << 4) | AUDIO_PACKET_SEQUENCE_START); + body.put_slice(b"Opus"); + body.put_slice(head); + } + // AC-3 / E-AC-3 carry their config in each sync frame; no sequence header tag. + Flavor::Ac3 | Flavor::Eac3 => return Ok(None), + Flavor::Avc | Flavor::Hevc | Flavor::Av1 | Flavor::Vp9 => unreachable!("video flavor on an audio track"), + } + Ok(Some(body)) +} + +/// Append an enhanced-RTMP video `SequenceStart` tag body: ex-header + FourCC + +/// the codec config record. +fn ex_video_sequence_start(body: &mut BytesMut, fourcc: &[u8; 4], config: &[u8]) { + body.put_u8(VIDEO_EX_HEADER | (FRAME_TYPE_KEY << 4) | VIDEO_PACKET_SEQUENCE_START); + body.put_slice(fourcc); + body.put_slice(config); +} + +/// Build a minimal `AV1CodecConfigurationRecord` (av1C) from the catalog AV1 +/// struct, with an empty `configOBUs` (the sequence header is carried in band). +fn av1c_bytes(av1: &AV1) -> [u8; 4] { + let high_bitdepth = av1.bitdepth >= 10; + let twelve_bit = av1.bitdepth >= 12; + [ + 0x81, // marker (1) + version (1) + ((av1.profile & 0x07) << 5) | (av1.level & 0x1f), + ((av1.tier == 'H') as u8) << 7 + | (high_bitdepth as u8) << 6 + | (twelve_bit as u8) << 5 + | (av1.mono_chrome as u8) << 4 + | (av1.chroma_subsampling_x as u8) << 3 + | (av1.chroma_subsampling_y as u8) << 2 + | (av1.chroma_sample_position & 0x03), + 0x00, // no initial presentation delay + ] } diff --git a/rs/moq-mux/src/container/flv/export_test.rs b/rs/moq-mux/src/container/flv/export_test.rs index b182a41fc..dcf3b60c6 100644 --- a/rs/moq-mux/src/container/flv/export_test.rs +++ b/rs/moq-mux/src/container/flv/export_test.rs @@ -245,3 +245,95 @@ async fn export_preserves_timestamps() { .collect(); assert_eq!(video_ts, vec![0, 33]); } + +/// A real VP9 key frame (profile 0, 320x240) from the VP9 parser's test vector. +const VP9_KEYFRAME: &[u8] = &[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]; + +/// Build an enhanced-RTMP FLV: VP9 video + Opus audio via the FourCC payloads. +fn synth_enhanced_flv() -> Vec { + let head = crate::codec::opus::Config { + sample_rate: 48000, + channel_count: 2, + } + .encode(); + + let mut out = Vec::new(); + out.extend_from_slice(b"FLV"); + out.push(1); + out.push(0x05); // audio | video + out.extend_from_slice(&9u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + + // Opus sequence start. + let mut aseq = vec![(super::AUDIO_FORMAT_EX << 4) | super::AUDIO_PACKET_SEQUENCE_START]; + aseq.extend_from_slice(b"Opus"); + aseq.extend_from_slice(&head); + write_tag(&mut out, super::TAG_AUDIO, 0, &aseq); + + // VP9 key frame (enhanced CodedFrames, no composition time). + let mut vkey = vec![super::VIDEO_EX_HEADER | (super::FRAME_TYPE_KEY << 4) | super::VIDEO_PACKET_CODED_FRAMES]; + vkey.extend_from_slice(b"vp09"); + vkey.extend_from_slice(VP9_KEYFRAME); + write_tag(&mut out, super::TAG_VIDEO, 0, &vkey); + + // One Opus frame. + let mut a0 = vec![(super::AUDIO_FORMAT_EX << 4) | super::AUDIO_PACKET_CODED_FRAMES]; + a0.extend_from_slice(b"Opus"); + a0.extend_from_slice(&[0xfc, 0xff, 0xfe]); + write_tag(&mut out, super::TAG_AUDIO, 0, &a0); + + out +} + +/// Enhanced codecs (VP9 video, Opus audio) survive an import -> export -> import +/// round trip as enhanced-RTMP FourCC payloads. +#[tokio::test(start_paused = true)] +async fn export_roundtrips_enhanced() { + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let consumer = producer.consume(); + let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + + let mut importer = Import::new(producer, catalog.clone()); + importer + .decode(&mut bytes::BytesMut::from(synth_enhanced_flv().as_slice())) + .unwrap(); + catalog.finish().unwrap(); + + let exporter = Export::new(consumer).unwrap(); + let exported = drain_export(exporter, importer).await; + let tags = parse_tags(&exported); + + // The video frame is an enhanced (FourCC) vp09 tag. + assert!( + tags.iter().any(|t| t.tag_type == super::TAG_VIDEO + && t.body[0] & super::VIDEO_EX_HEADER != 0 + && &t.body[1..5] == b"vp09"), + "expected an enhanced vp09 video tag" + ); + // The audio carries an enhanced Opus sequence header (not a coded-frame tag). + assert!( + tags.iter().any(|t| t.tag_type == super::TAG_AUDIO + && t.body[0] >> 4 == super::AUDIO_FORMAT_EX + && t.body[0] & 0x0f == super::AUDIO_PACKET_SEQUENCE_START + && &t.body[1..5] == b"Opus"), + "expected an enhanced Opus sequence-header tag" + ); + + // Re-import and confirm the codecs survive. + let mut bcast2 = moq_net::Broadcast::new().produce(); + let cat2 = crate::catalog::Producer::new(&mut bcast2).unwrap(); + let mut imp2 = Import::new(bcast2, cat2.clone()); + imp2.decode(&mut bytes::BytesMut::from(exported.as_slice())).unwrap(); + imp2.finish().unwrap(); + + let snap = cat2.snapshot(); + assert!(matches!( + snap.video.renditions.values().next().unwrap().codec, + VideoCodec::VP9(_) + )); + assert!(matches!( + snap.audio.renditions.values().next().unwrap().codec, + AudioCodec::Opus + )); +} diff --git a/rs/moq-mux/src/container/flv/import.rs b/rs/moq-mux/src/container/flv/import.rs index 3492053b9..f0b3ccfdb 100644 --- a/rs/moq-mux/src/container/flv/import.rs +++ b/rs/moq-mux/src/container/flv/import.rs @@ -1,21 +1,33 @@ //! FLV demuxer. //! //! [`Import`] reads an FLV byte stream, splits it into tags, and routes the -//! H.264 (AVC) video and AAC audio onto MoQ tracks. FLV carries codec config -//! out of band: the AVC sequence header is an `AVCDecoderConfigurationRecord` -//! (avcC) and the AAC sequence header is an `AudioSpecificConfig`, both reused -//! verbatim as the catalog `description`. Video access units are already -//! length-prefixed NALU (the avc1 shape) and audio frames are raw AAC, so the -//! sample bytes pass through to the [`Legacy`](crate::catalog::hang::Container) -//! container unchanged. +//! video and audio onto MoQ tracks. Two payload generations are handled: +//! +//! - **Legacy FLV/RTMP**: H.264 (AVC) video carried as length-prefixed NALU with +//! an out-of-band `AVCDecoderConfigurationRecord` (avcC), and AAC audio with an +//! out-of-band `AudioSpecificConfig`. +//! - **Enhanced RTMP (E-RTMP)**: the FourCC-signaled payloads OBS and ffmpeg emit +//! for HEVC (`hvc1`), AV1 (`av01`), VP9 (`vp09`), and the legacy AVC FourCC +//! (`avc1`); plus enhanced audio for Opus (`Opus`), AC-3 (`ac-3`), E-AC-3 +//! (`ec-3`), and AAC (`mp4a`). +//! +//! Each codec's out-of-band config record (avcC / hvcC / av1C / `AudioSpecificConfig` +//! / `OpusHead`) becomes the catalog `description`; VP9 and the verbatim audio +//! codecs (AC-3 / E-AC-3) carry their config in band, so they configure from the +//! first frame instead. Sample bytes already match the [`Legacy`](crate::catalog::hang::Container) +//! container, so no codec transform is needed. FLAC (`fLaC`) and MP3 (`.mp3`) +//! enhanced audio, and any other codec, are logged and dropped. use bytes::{Buf, Bytes, BytesMut}; -use hang::catalog::{AAC, AudioConfig, Container, H264, VideoConfig}; +use hang::catalog::{AAC, AudioCodec, AudioConfig, Container, H264, VideoConfig}; use tokio::io::{AsyncRead, AsyncReadExt}; use super::{ - AAC_RAW, AAC_SEQUENCE_HEADER, AUDIO_FORMAT_AAC, AVC_NALU, AVC_SEQUENCE_HEADER, FILE_HEADER_LEN, FRAME_TYPE_KEY, - PREV_TAG_SIZE_LEN, TAG_AUDIO, TAG_HEADER_LEN, TAG_SCRIPT, TAG_VIDEO, VIDEO_CODEC_AVC, read_i24, read_u24, + AAC_RAW, AAC_SEQUENCE_HEADER, AUDIO_FORMAT_AAC, AUDIO_FORMAT_EX, AUDIO_PACKET_CODED_FRAMES, + AUDIO_PACKET_MULTICHANNEL_CONFIG, AUDIO_PACKET_SEQUENCE_END, AUDIO_PACKET_SEQUENCE_START, AVC_NALU, + AVC_SEQUENCE_HEADER, FILE_HEADER_LEN, FRAME_TYPE_KEY, PREV_TAG_SIZE_LEN, TAG_AUDIO, TAG_HEADER_LEN, TAG_SCRIPT, + TAG_VIDEO, VIDEO_CODEC_AVC, VIDEO_EX_HEADER, VIDEO_PACKET_CODED_FRAMES, VIDEO_PACKET_CODED_FRAMES_X, + VIDEO_PACKET_METADATA, VIDEO_PACKET_SEQUENCE_END, VIDEO_PACKET_SEQUENCE_START, read_i24, read_u24, }; use crate::container::{Frame, Timestamp}; @@ -25,9 +37,9 @@ const MAX_DATA_OFFSET: usize = 64 * 1024; /// Demuxes an FLV byte stream into a MoQ broadcast. /// -/// Supports H.264 (CodecID 7) video and AAC (SoundFormat 10) audio, the modern -/// FLV payload produced by RTMP encoders and `ffmpeg -f flv`. Every other codec, -/// plus the enhanced E-RTMP FourCC tags and `onMetaData` script tags, is logged +/// Supports legacy H.264 + AAC and the enhanced-RTMP FourCC codecs (HEVC, AV1, +/// VP9, Opus, AC-3, E-AC-3), the payloads produced by RTMP encoders and +/// `ffmpeg -f flv`. Unsupported codecs, plus `onMetaData` script tags, are logged /// and dropped. A single FLV stream carries at most one video and one audio /// track; a new sequence header replaces the previous configuration. pub struct Import { @@ -40,15 +52,21 @@ pub struct Import { /// True once the 9-byte FLV file header and its `PreviousTagSize0` have been consumed. header_seen: bool, - video: Option, - audio: Option, + video: Option, + audio: Option, } -/// One demuxed track: its producer plus the sequence-header bytes last seen, so a -/// repeated (identical) sequence header is a no-op rather than a track rebuild. -struct Stream { +/// The demuxed video track plus its current catalog config, so a repeated +/// (identical) sequence header is a no-op rather than a track rebuild. +struct VideoStream { track: crate::container::Producer, - description: Bytes, + config: VideoConfig, +} + +/// The demuxed audio track plus its current catalog config. +struct AudioStream { + track: crate::container::Producer, + config: AudioConfig, } impl Import { @@ -149,11 +167,11 @@ impl Import { return Ok(()); }; // The enhanced E-RTMP signaling sets the high bit and switches to FourCC - // codec identification; we only speak classic AVC. - if first & 0x80 != 0 { - tracing::warn!("enhanced FLV (FourCC) video not supported, dropping"); - return Ok(()); + // codec identification. + if first & VIDEO_EX_HEADER != 0 { + return self.handle_video_enhanced(first, body, timestamp); } + let frame_type = first >> 4; let codec_id = first & 0x0f; if codec_id != VIDEO_CODEC_AVC { @@ -167,24 +185,73 @@ impl Import { let data = &body[5..]; match avc_packet_type { - AVC_SEQUENCE_HEADER => self.init_video(data), - AVC_NALU => { - let Some(stream) = self.video.as_mut() else { - tracing::debug!("AVC NALU before sequence header, dropping"); - return Ok(()); + AVC_SEQUENCE_HEADER => self.init_video(config_from_avcc(data)?), + AVC_NALU => self.write_video(data, timestamp, composition_time, frame_type == FRAME_TYPE_KEY), + // AVCPacketType 2 is "end of sequence"; nothing to emit. + _ => Ok(()), + } + } + + /// Handle an enhanced-RTMP (FourCC) video tag. + fn handle_video_enhanced(&mut self, first: u8, body: &[u8], timestamp: u64) -> anyhow::Result<()> { + let frame_type = (first >> 4) & 0x07; + let packet_type = first & 0x0f; + anyhow::ensure!(body.len() >= 5, "enhanced video tag too short for FourCC"); + let fourcc: [u8; 4] = body[1..5].try_into().expect("slice is 4 bytes"); + let payload = &body[5..]; + let keyframe = frame_type == FRAME_TYPE_KEY; + + match packet_type { + VIDEO_PACKET_SEQUENCE_START => { + let config = match &fourcc { + b"avc1" => config_from_avcc(payload)?, + b"hvc1" => crate::codec::h265::config_from_hvcc(payload)?, + b"av01" => crate::codec::av1::config_from_av1c(payload)?, + // VP9 carries its config in band; the SequenceStart vpcC is + // redundant with the key-frame header we configure from. + b"vp09" => return Ok(()), + other => { + tracing::warn!(fourcc = ?other, "unsupported enhanced FLV video codec, dropping"); + return Ok(()); + } }; - // FLV stores DTS in the tag; PTS is DTS plus the composition offset. - let pts_ms = (timestamp as i64) + (composition_time as i64); - anyhow::ensure!(pts_ms >= 0, "negative AVC presentation timestamp"); - stream.track.write(Frame { - timestamp: Timestamp::from_millis(pts_ms as u64)?, - payload: Bytes::copy_from_slice(data), - keyframe: frame_type == FRAME_TYPE_KEY, - })?; + self.init_video(config) + } + VIDEO_PACKET_CODED_FRAMES | VIDEO_PACKET_CODED_FRAMES_X => { + // hvc1/avc1 CodedFrames prefix a 3-byte composition time; CodedFramesX + // and the always-zero-offset av01/vp09 do not. + let has_cts = packet_type == VIDEO_PACKET_CODED_FRAMES && matches!(&fourcc, b"hvc1" | b"avc1"); + let (data, cts) = if has_cts { + anyhow::ensure!(payload.len() >= 3, "enhanced CodedFrames missing composition time"); + (&payload[3..], read_i24(&payload[0..3])) + } else { + (payload, 0) + }; + + // VP9 has no out-of-band config record, so (re)configure from each key + // frame's uncompressed header. `init_video` dedups when unchanged, so + // this is a no-op except on the first key frame or a resolution change. + // A malformed header drops just this frame rather than aborting the stream. + if &fourcc == b"vp09" && keyframe { + match crate::codec::vp9::config_from_keyframe(data) { + Ok(Some(config)) => self.init_video(config)?, + Ok(None) => {} + Err(err) => { + // The header didn't parse, so the frame is unusable: drop it + // rather than forwarding a frame we couldn't validate. + tracing::warn!(%err, "dropping malformed VP9 key frame"); + return Ok(()); + } + } + } + + self.write_video(data, timestamp, cts, keyframe) + } + VIDEO_PACKET_SEQUENCE_END | VIDEO_PACKET_METADATA => Ok(()), + other => { + tracing::debug!(packet_type = other, "ignoring enhanced FLV video packet type"); Ok(()) } - // AVCPacketType 2 is "end of sequence"; nothing to emit. - _ => Ok(()), } } @@ -193,6 +260,9 @@ impl Import { return Ok(()); }; let sound_format = first >> 4; + if sound_format == AUDIO_FORMAT_EX { + return self.handle_audio_enhanced(first, body, timestamp); + } if sound_format != AUDIO_FORMAT_AAC { tracing::warn!(sound_format, "unsupported FLV audio format, dropping"); return Ok(()); @@ -203,80 +273,126 @@ impl Import { let data = &body[2..]; match aac_packet_type { - AAC_SEQUENCE_HEADER => self.init_audio(data), - AAC_RAW => { - let Some(stream) = self.audio.as_mut() else { - tracing::debug!("AAC frame before sequence header, dropping"); - return Ok(()); + AAC_SEQUENCE_HEADER => self.init_audio(config_from_asc(data)?), + AAC_RAW => self.write_audio(data, timestamp), + _ => Ok(()), + } + } + + /// Handle an enhanced-RTMP (FourCC) audio tag. + fn handle_audio_enhanced(&mut self, first: u8, body: &[u8], timestamp: u64) -> anyhow::Result<()> { + let packet_type = first & 0x0f; + anyhow::ensure!(body.len() >= 5, "enhanced audio tag too short for FourCC"); + let fourcc: [u8; 4] = body[1..5].try_into().expect("slice is 4 bytes"); + let payload = &body[5..]; + + match packet_type { + AUDIO_PACKET_SEQUENCE_START => { + let config = match &fourcc { + b"Opus" => config_from_opus_head(payload)?, + b"mp4a" => config_from_asc(payload)?, + // AC-3 / E-AC-3 are verbatim with no sequence header; they + // configure from the first frame. Anything else is unsupported. + other => { + tracing::warn!(fourcc = ?other, "unsupported enhanced FLV audio codec, dropping"); + return Ok(()); + } }; - // Each frame is its own group so the relay can forward it immediately. - stream.track.write(Frame { - timestamp: Timestamp::from_millis(timestamp)?, - payload: Bytes::copy_from_slice(data), - keyframe: true, - })?; - stream.track.finish_group()?; + self.init_audio(config) + } + AUDIO_PACKET_CODED_FRAMES => { + // AC-3 / E-AC-3 carry their config in the frame header, so configure + // from the first frame when no sequence header preceded it. + if self.audio.is_none() { + let config = match &fourcc { + b"ac-3" => Some(config_from_ac3(payload)?), + b"ec-3" => Some(config_from_eac3(payload)?), + _ => None, + }; + if let Some(config) = config { + self.init_audio(config)?; + } + } + self.write_audio(payload, timestamp) + } + AUDIO_PACKET_SEQUENCE_END | AUDIO_PACKET_MULTICHANNEL_CONFIG => Ok(()), + other => { + tracing::debug!(packet_type = other, "ignoring enhanced FLV audio packet type"); Ok(()) } - _ => Ok(()), } } - /// Handle an AVC sequence header (an `AVCDecoderConfigurationRecord`). On the - /// first one, or whenever the bytes change, (re)build the video track. - fn init_video(&mut self, avcc_bytes: &[u8]) -> anyhow::Result<()> { - if self.video.as_ref().is_some_and(|s| s.description == avcc_bytes) { + /// Write one decoded video sample. A leading delta before the first keyframe + /// (a mid-GOP join) is tolerated by the lenient-start producer rather than + /// aborting. + fn write_video(&mut self, data: &[u8], dts: u64, composition_time: i32, keyframe: bool) -> anyhow::Result<()> { + let Some(stream) = self.video.as_mut() else { + tracing::debug!("video frame before sequence header, dropping"); return Ok(()); - } + }; + // FLV stores DTS in the tag; PTS is DTS plus the composition offset. + let pts_ms = (dts as i64) + (composition_time as i64); + anyhow::ensure!(pts_ms >= 0, "negative video presentation timestamp"); + stream.track.write(Frame { + timestamp: Timestamp::from_millis(pts_ms as u64)?, + payload: Bytes::copy_from_slice(data), + keyframe, + })?; + Ok(()) + } - let avcc = crate::codec::h264::Avcc::parse(avcc_bytes)?; - let mut config = VideoConfig::new(H264 { - profile: avcc.profile, - constraints: avcc.constraints, - level: avcc.level, - inline: false, - }); - config.description = Some(Bytes::copy_from_slice(avcc_bytes)); - config.coded_width = avcc.coded_width; - config.coded_height = avcc.coded_height; - config.container = Container::Legacy; + /// Write one audio frame as its own group, so the relay can forward it immediately. + fn write_audio(&mut self, data: &[u8], timestamp: u64) -> anyhow::Result<()> { + let Some(stream) = self.audio.as_mut() else { + tracing::debug!("audio frame before config, dropping"); + return Ok(()); + }; + stream.track.write(Frame { + timestamp: Timestamp::from_millis(timestamp)?, + payload: Bytes::copy_from_slice(data), + keyframe: true, + })?; + stream.track.finish_group()?; + Ok(()) + } + + /// (Re)build the video track for `config`, unless it matches the current one. + fn init_video(&mut self, config: VideoConfig) -> anyhow::Result<()> { + if self.video.as_ref().is_some_and(|s| s.config == config) { + return Ok(()); + } let net_track = self.replace_video()?; self.catalog .lock() .video .renditions - .insert(net_track.name.clone(), config); - self.video = Some(Stream { + .insert(net_track.name.clone(), config.clone()); + self.video = Some(VideoStream { // Live FLV can join mid-GOP; tolerate leading deltas before the first keyframe. track: crate::container::Producer::new(net_track, crate::catalog::hang::Container::Legacy) .with_lenient_start(), - description: Bytes::copy_from_slice(avcc_bytes), + config, }); Ok(()) } - /// Handle an AAC sequence header (an `AudioSpecificConfig`). - fn init_audio(&mut self, asc_bytes: &[u8]) -> anyhow::Result<()> { - if self.audio.as_ref().is_some_and(|s| s.description == asc_bytes) { + /// (Re)build the audio track for `config`, unless it matches the current one. + fn init_audio(&mut self, config: AudioConfig) -> anyhow::Result<()> { + if self.audio.as_ref().is_some_and(|s| s.config == config) { return Ok(()); } - let mut cursor = asc_bytes; - let cfg = crate::codec::aac::Config::parse(&mut cursor)?; - let mut config = AudioConfig::new(AAC { profile: cfg.profile }, cfg.sample_rate, cfg.channel_count); - config.description = Some(Bytes::copy_from_slice(asc_bytes)); - config.container = Container::Legacy; - let net_track = self.replace_audio()?; self.catalog .lock() .audio .renditions - .insert(net_track.name.clone(), config); - self.audio = Some(Stream { + .insert(net_track.name.clone(), config.clone()); + self.audio = Some(AudioStream { track: crate::container::Producer::new(net_track, crate::catalog::hang::Container::Legacy), - description: Bytes::copy_from_slice(asc_bytes), + config, }); Ok(()) } @@ -335,3 +451,55 @@ impl Drop for Import { } } } + +/// Build a video config for the `avc1` shape from an `AVCDecoderConfigurationRecord`. +fn config_from_avcc(avcc_bytes: &[u8]) -> anyhow::Result { + let avcc = crate::codec::h264::Avcc::parse(avcc_bytes)?; + let mut config = VideoConfig::new(H264 { + profile: avcc.profile, + constraints: avcc.constraints, + level: avcc.level, + inline: false, + }); + config.description = Some(Bytes::copy_from_slice(avcc_bytes)); + config.coded_width = avcc.coded_width; + config.coded_height = avcc.coded_height; + config.container = Container::Legacy; + Ok(config) +} + +/// Build an audio config for AAC from an `AudioSpecificConfig`. +fn config_from_asc(asc_bytes: &[u8]) -> anyhow::Result { + let mut cursor = asc_bytes; + let cfg = crate::codec::aac::Config::parse(&mut cursor)?; + let mut config = AudioConfig::new(AAC { profile: cfg.profile }, cfg.sample_rate, cfg.channel_count); + config.description = Some(Bytes::copy_from_slice(asc_bytes)); + config.container = Container::Legacy; + Ok(config) +} + +/// Build an audio config for Opus from an `OpusHead` (RFC 7845) record. +fn config_from_opus_head(head: &[u8]) -> anyhow::Result { + let mut cursor = head; + let cfg = crate::codec::opus::Config::parse(&mut cursor)?; + let mut config = AudioConfig::new(AudioCodec::Opus, cfg.sample_rate, cfg.channel_count); + config.description = Some(Bytes::copy_from_slice(head)); + config.container = Container::Legacy; + Ok(config) +} + +/// Build an audio config for AC-3 from a sync frame header. +fn config_from_ac3(frame: &[u8]) -> anyhow::Result { + let header = crate::codec::ac3::parse_header(frame)?; + let mut config = AudioConfig::new(AudioCodec::Ac3, header.sample_rate, header.channel_count); + config.container = Container::Legacy; + Ok(config) +} + +/// Build an audio config for E-AC-3 from a sync frame header. +fn config_from_eac3(frame: &[u8]) -> anyhow::Result { + let header = crate::codec::eac3::parse_header(frame)?; + let mut config = AudioConfig::new(AudioCodec::Ec3, header.sample_rate, header.channel_count); + config.container = Container::Legacy; + Ok(config) +} diff --git a/rs/moq-mux/src/container/flv/import_test.rs b/rs/moq-mux/src/container/flv/import_test.rs index fb881fdbb..4f495f210 100644 --- a/rs/moq-mux/src/container/flv/import_test.rs +++ b/rs/moq-mux/src/container/flv/import_test.rs @@ -146,6 +146,88 @@ async fn import_handles_split_input() { assert_eq!(snap.audio.renditions.len(), 1); } +/// A real VP9 key frame (profile 0, 320x240) from the VP9 parser's test vector. +const VP9_KEYFRAME: &[u8] = &[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]; + +/// Enhanced-RTMP (FourCC) VP9 video configures from the key frame and emits it. +#[tokio::test(start_paused = true)] +async fn import_enhanced_vp9() { + let mut out = Vec::new(); + out.extend_from_slice(b"FLV"); + out.push(1); + out.push(0x01); // video only + out.extend_from_slice(&9u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + + // Ex-video CodedFrames keyframe: high bit set, frame type 1, packet type 1. + let first = super::VIDEO_EX_HEADER | (super::FRAME_TYPE_KEY << 4) | super::VIDEO_PACKET_CODED_FRAMES; + let mut body = vec![first]; + body.extend_from_slice(b"vp09"); + body.extend_from_slice(VP9_KEYFRAME); + write_tag(&mut out, super::TAG_VIDEO, 0, &body); + + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + + let mut importer = Import::new(producer, catalog.clone()); + importer.decode(&mut bytes::BytesMut::from(out.as_slice())).unwrap(); + importer.finish().unwrap(); + + let snap = catalog.snapshot(); + assert_eq!(snap.video.renditions.len(), 1); + let v = snap.video.renditions.values().next().unwrap(); + assert!(matches!(v.codec, VideoCodec::VP9(_))); + assert_eq!(v.coded_width, Some(320)); + assert_eq!(v.coded_height, Some(240)); +} + +/// Enhanced-RTMP (FourCC) Opus audio configures from the `OpusHead` sequence +/// header and carries the frames through. +#[tokio::test(start_paused = true)] +async fn import_enhanced_opus() { + let head = crate::codec::opus::Config { + sample_rate: 48000, + channel_count: 2, + } + .encode(); + + let mut out = Vec::new(); + out.extend_from_slice(b"FLV"); + out.push(1); + out.push(0x04); // audio only + out.extend_from_slice(&9u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + + // Ex-audio SequenceStart: SoundFormat 9, packet type 0. + let mut seq = vec![(super::AUDIO_FORMAT_EX << 4) | super::AUDIO_PACKET_SEQUENCE_START]; + seq.extend_from_slice(b"Opus"); + seq.extend_from_slice(&head); + write_tag(&mut out, super::TAG_AUDIO, 0, &seq); + + // Ex-audio CodedFrames: SoundFormat 9, packet type 1. + let mut frame = vec![(super::AUDIO_FORMAT_EX << 4) | super::AUDIO_PACKET_CODED_FRAMES]; + frame.extend_from_slice(b"Opus"); + frame.extend_from_slice(&[0xfc, 0xff, 0xfe]); + write_tag(&mut out, super::TAG_AUDIO, 20, &frame); + + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + + let mut importer = Import::new(producer, catalog.clone()); + importer.decode(&mut bytes::BytesMut::from(out.as_slice())).unwrap(); + importer.finish().unwrap(); + + let snap = catalog.snapshot(); + assert_eq!(snap.audio.renditions.len(), 1); + let a = snap.audio.renditions.values().next().unwrap(); + assert!(matches!(a.codec, AudioCodec::Opus)); + assert_eq!(a.sample_rate, 48000); + assert_eq!(a.channel_count, 2); + assert_eq!(a.description.as_ref().map(|b| b.as_ref()), Some(head.as_ref())); +} + #[tokio::test(start_paused = true)] async fn import_rejects_non_flv() { let broadcast = moq_net::Broadcast::new(); diff --git a/rs/moq-mux/src/container/flv/mod.rs b/rs/moq-mux/src/container/flv/mod.rs index e05dd0680..c911e2e76 100644 --- a/rs/moq-mux/src/container/flv/mod.rs +++ b/rs/moq-mux/src/container/flv/mod.rs @@ -1,15 +1,21 @@ //! FLV (Flash Video / RTMP container). //! //! An interchange format only, not a wire format: [`Import`] demuxes an FLV byte -//! stream into a broadcast and [`Export`] muxes a broadcast back into FLV. Only -//! the modern FLV payload is handled: H.264 (AVC) video carried as -//! length-prefixed NALU with an out-of-band `AVCDecoderConfigurationRecord` -//! (avcC), and AAC audio carried raw with an out-of-band `AudioSpecificConfig`. -//! Both records pass straight through as the catalog `description`, and the -//! sample bytes already match the [`Legacy`](crate::catalog::hang::Container) -//! container, so no codec transform is needed. Other legacy codecs (VP6, MP3, -//! Speex, …) and the enhanced E-RTMP FourCC payloads are logged and dropped on -//! import, and rejected on export. +//! stream into a broadcast and [`Export`] muxes a broadcast back into FLV. Two +//! payload generations are handled: +//! +//! - **Legacy FLV/RTMP**: H.264 (AVC) video as length-prefixed NALU with an +//! out-of-band `AVCDecoderConfigurationRecord` (avcC), and AAC audio with an +//! out-of-band `AudioSpecificConfig`. +//! - **Enhanced RTMP (E-RTMP)**: the FourCC payloads for HEVC (`hvc1`), AV1 +//! (`av01`), VP9 (`vp09`), Opus (`Opus`), AC-3 (`ac-3`), and E-AC-3 (`ec-3`). +//! +//! Each codec's config record passes straight through as the catalog +//! `description` (or, for the in-band codecs VP9 / AC-3 / E-AC-3, is read from the +//! frame), and the sample bytes already match the +//! [`Legacy`](crate::catalog::hang::Container) container, so no codec transform is +//! needed. Other legacy codecs (VP6, MP3, Speex, …) and the E-RTMP FLAC / MP3 +//! audio are logged and dropped on import, and rejected on export. mod export; mod import; @@ -61,6 +67,28 @@ const AAC_RAW: u8 = 1; /// channel layout live in the `AudioSpecificConfig`, so these bits are nominal. const AAC_AUDIO_TAG_HEADER: u8 = (AUDIO_FORMAT_AAC << 4) | (3 << 2) | (1 << 1) | 1; +/// Enhanced-RTMP (E-RTMP) video signaling: a set high bit on a video tag's +/// first byte switches from a legacy CodecID to a FourCC codec + packet type. +const VIDEO_EX_HEADER: u8 = 0x80; + +/// Enhanced video `VideoPacketType` (low nibble of an ex-video tag's first byte). +const VIDEO_PACKET_SEQUENCE_START: u8 = 0; +const VIDEO_PACKET_CODED_FRAMES: u8 = 1; +const VIDEO_PACKET_SEQUENCE_END: u8 = 2; +/// Coded frames with the composition-time offset omitted (always zero). +const VIDEO_PACKET_CODED_FRAMES_X: u8 = 3; +const VIDEO_PACKET_METADATA: u8 = 4; + +/// Enhanced-RTMP audio signaling: SoundFormat 9 in the high nibble of an audio +/// tag's first byte switches to a FourCC codec + packet type. +const AUDIO_FORMAT_EX: u8 = 9; + +/// Enhanced audio `AudioPacketType` (low nibble of an ex-audio tag's first byte). +const AUDIO_PACKET_SEQUENCE_START: u8 = 0; +const AUDIO_PACKET_CODED_FRAMES: u8 = 1; +const AUDIO_PACKET_SEQUENCE_END: u8 = 2; +const AUDIO_PACKET_MULTICHANNEL_CONFIG: u8 = 4; + /// Read a 24-bit big-endian unsigned integer. fn read_u24(b: &[u8]) -> u32 { ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32) diff --git a/rs/moq-native/src/tls.rs b/rs/moq-native/src/tls.rs index 80acb682f..d6efe458e 100644 --- a/rs/moq-native/src/tls.rs +++ b/rs/moq-native/src/tls.rs @@ -77,6 +77,10 @@ pub enum Error { #[error(transparent)] Rustls(#[from] rustls::Error), + #[cfg(any(feature = "quinn", feature = "noq"))] + #[error("failed to build client certificate verifier")] + ClientVerifier(#[source] rustls::server::VerifierBuilderError), + #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] #[error(transparent)] Rcgen(#[from] rcgen::Error), @@ -500,6 +504,47 @@ impl Server { } Ok(roots) } + + /// Build a [`rustls::ServerConfig`] for a plain-TLS (non-QUIC) server, e.g. an + /// RTMPS or HTTPS listener fronting the QUIC endpoint, reusing the QUIC + /// backend's certificate handling: on-disk `cert`/`key` pairs, `generate` + /// self-signed certs, and optional mTLS `root` client CAs. + /// + /// `alpn` sets the advertised ALPN protocols (e.g. + /// `vec![b"h2".to_vec(), b"http/1.1".to_vec()]`); pass an empty list for a + /// protocol like RTMPS that doesn't use ALPN. + #[cfg(any(feature = "noq", feature = "quinn"))] + pub fn server_config(&self, alpn: Vec>) -> Result> { + server_config(self, alpn) + } +} + +/// Build a [`rustls::ServerConfig`] from a [`Server`] for a plain-TLS listener. +#[cfg(any(feature = "noq", feature = "quinn"))] +fn server_config(config: &Server, alpn: Vec>) -> Result> { + let provider = crypto::provider(); + + let certs = ServeCerts::new(provider.clone()); + certs.load_certs(config)?; + let certs = Arc::new(certs); + + // TCP can negotiate TLS 1.2 as well as 1.3, unlike QUIC which is 1.3-only. + let builder = + rustls::ServerConfig::builder_with_provider(provider.clone()).with_safe_default_protocol_versions()?; + + let mut tls = if config.root.is_empty() { + builder.with_no_client_auth().with_cert_resolver(certs) + } else { + let roots = config.load_roots()?; + let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider) + .allow_unauthenticated() + .build() + .map_err(Error::ClientVerifier)?; + builder.with_client_cert_verifier(verifier).with_cert_resolver(certs) + }; + + tls.alpn_protocols = alpn; + Ok(Arc::new(tls)) } /// A peer's validated client-certificate chain from the mTLS handshake. diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml new file mode 100644 index 000000000..108f8e1b4 --- /dev/null +++ b/rs/moq-rtmp/Cargo.toml @@ -0,0 +1,72 @@ +[package] +name = "moq-rtmp" +description = "RTMP / enhanced-RTMP contribution ingest gateway for Media over QUIC" +authors = ["Luke Curley "] +repository = "https://github.com/moq-dev/moq" +license = "MIT OR Apache-2.0" + +version = "0.0.1" +edition = "2024" +rust-version.workspace = true + +keywords = ["rtmp", "moq", "media", "live", "flv"] +categories = ["multimedia", "network-programming", "web-programming"] + +[lib] +doctest = false + +[[bin]] +name = "moq-rtmp" +path = "bin/moq-rtmp.rs" +doc = false +# The binary (relay client/server, CLI) needs the `server` feature; the library +# can be depended on with `default-features = false` for ingest only (e.g. a +# relay that embeds the RTMP listener and publishes into its own origin). +required-features = ["server"] + +[features] +default = ["server", "noq", "websocket"] +# Relay client/server transports + the moq-rtmp binary. Pulls in moq-native / +# axum / clap / rustls. +server = ["dep:axum", "dep:axum-server", "dep:clap", "dep:moq-native", "dep:rustls", "dep:sd-notify", "dep:tokio-rustls", "dep:tower-http", "dep:url"] +noq = ["server", "moq-native/noq"] +quinn = ["server", "moq-native/quinn"] +quiche = ["server", "moq-native/quiche"] +websocket = ["server", "moq-native/websocket"] +iroh = ["server", "moq-native/iroh"] + +# The ingest library needs only anyhow/bytes/moq-mux/moq-net/rml_rtmp/thiserror/ +# tokio/tracing; the rest (axum/clap/moq-native/rustls/tokio-rustls/tower-http/ +# url/sd-notify) are `optional` and pulled in by the `server` feature. RTMPS +# (Config::tls / Server::with_tls) needs rustls + tokio-rustls, so it's +# server-gated too. +[dependencies] +anyhow = { version = "1", features = ["backtrace"] } +axum = { version = "0.8", features = ["tokio"], optional = true } +axum-server = { version = "0.8", features = ["tls-rustls"], optional = true } +bytes = "1" +clap = { version = "4", features = ["derive"], optional = true } +futures = "0.3" +moq-mux = { workspace = true } +moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"], optional = true } +moq-net = { workspace = true } +# Pure-Rust RTMP (no librtmp / ffmpeg): handshake + chunk codec + the +# server-session state machine. RTMP carries FLV, demuxed by moq-mux. +rml_rtmp = "0.8" +rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"], optional = true } +# TCP keepalive on accepted sockets, so a half-open connection (a peer that +# vanished without a FIN/RST) is reaped instead of pinning a broadcast and its +# stream-key slot forever. +socket2 = "0.6" +thiserror = "2" +tokio = { workspace = true, features = ["full"] } +# RTMPS: TLS-terminate accepted connections. Mirrors moq-relay's pinning so the +# workspace shares one tokio-rustls; the crypto provider comes from the supplied +# `rustls::ServerConfig`, so no default crypto feature is needed here. +tokio-rustls = { version = "0.26", default-features = false, optional = true } +tower-http = { version = "0.6", features = ["cors", "fs"], optional = true } +tracing = "0.1" +url = { version = "2", optional = true } + +[target.'cfg(unix)'.dependencies] +sd-notify = { version = "0.5", optional = true } diff --git a/rs/moq-rtmp/README.md b/rs/moq-rtmp/README.md new file mode 100644 index 000000000..2d6467f2e --- /dev/null +++ b/rs/moq-rtmp/README.md @@ -0,0 +1,183 @@ +# moq-rtmp + +RTMP / enhanced-RTMP gateway for Media over QUIC: contribution ingest and egress. + +RTMP carries FLV-format audio/video. This crate runs an RTMP server that bridges +both directions with [`moq-mux`](../moq-mux): on **publish** it re-wraps a +client's messages as FLV tags, demuxes them, and publishes the result into a MoQ +origin as ordinary broadcasts; on **play** it subscribes to a broadcast from the +origin, muxes it back to FLV, and streams the tags down to the player. It's the +sibling of `moq-srt`, `moq-hls`'s import/export, and `moq-rtc`'s WHIP/WHEP. Both +legacy RTMP (H.264 + AAC) and enhanced RTMP (E-RTMP: HEVC, AV1, VP9, Opus, AC-3) +work in each direction, since the codec handling lives in the `moq-mux` FLV +demuxer/muxer. Pure Rust: the protocol is provided by `rml_rtmp`, with no librtmp +or ffmpeg dependency. + +## Library + +Depend on it with `default-features = false` to skip the binary's relay +client/server and CLI dependencies: + +```toml +moq-rtmp = { version = "0.0.1", default-features = false } +``` + +There are two entry points. + +### `run` (unauthenticated) + +`Config` + `run` accepts every publisher and player and routes by prefix + +app/key (publishers ingest into the origin, players are served out of it). A relay +embeds the gateway by calling `run` against its own origin, so the media stays +local with no extra hop: + +```rust +let mut rtmp = moq_rtmp::Config::default(); +rtmp.listen = Some("0.0.0.0:1935".parse()?); +rtmp.prefix = "live/".to_string(); + +// `origin` is your relay's local origin (e.g. `cluster.origin.clone()`). +tokio::select! { + res = moq_rtmp::run(origin, rtmp) => res?, + // ... your relay's accept loop, web server, etc. +} +``` + +### `Server` / `Request` (bring your own auth) + +To gate access, drive the `Server` directly. `accept` runs the handshake and the +connect exchange, then yields a `Request` once the client wants to publish or +play. The `Request` is either a `Publish` or a `Play`; you inspect the app and +stream key, make a decision, and `accept` or `reject` it. This mirrors +`moq-native`'s `Server` / `Request`, so there's no callback: the auth policy lives +in your loop. + +```rust +let mut server = moq_rtmp::Server::bind("0.0.0.0:1935".parse()?).await?; +let consumer = origin.consume(); // players are served out of this +while let Some(request) = server.accept().await { + let origin = origin.clone(); + let consumer = consumer.clone(); + // Spawn per connection: `accept` pumps media for the whole connection, so + // handling it inline would serialize clients. + tokio::spawn(async move { + // Treat the stream key as a token (e.g. a moq-token JWT) and the app as + // the broadcast path. Verify however you like; the origin can be scoped + // per token with `with_root` / `scope`. + match request { + moq_rtmp::Request::Publish(publish) => match authorize(publish.app(), publish.stream_key()).await { + Ok(path) => { let _ = publish.accept(&origin, &path).await; } + Err(err) => { let _ = publish.reject(&err.to_string()).await; } + }, + moq_rtmp::Request::Play(play) => match authorize(play.app(), play.stream_key()).await { + Ok(path) => { let _ = play.accept(&consumer, &path).await; } + Err(err) => { let _ = play.reject(&err.to_string()).await; } + }, + } + }); +} +``` + +### RTMPS (RTMP over TLS) + +Two ways to serve `rtmps://`: + +- **Let the gateway terminate TLS.** Set `Config::tls` (or call + `Server::with_tls`) with a `rustls::ServerConfig`, and the listener speaks + RTMPS with no other change. Build the config from a `moq_native::tls::Server` + instance (RTMPS has no ALPN), or supply any `rustls::ServerConfig`. To serve + both RTMP and RTMPS, run two listeners (`run` once per config) against a + cloned origin. + + ```rust + let mut tls = moq_native::tls::Server::default(); + tls.generate = vec!["your-domain.com".to_string()]; // or set tls.cert / tls.key + let server_config = tls.server_config(vec![])?; // RTMPS has no ALPN + + let mut rtmps = moq_rtmp::Config::default(); + rtmps.listen = Some("0.0.0.0:443".parse()?); + rtmps.tls = Some(server_config); // Arc + ``` + +- **Bring your own transport.** Accept the connection and complete the TLS + handshake yourself (or use any other `AsyncRead + AsyncWrite` stream: a proxy + socket, a test pipe), then hand the established stream to `accept_stream`, + which runs the RTMP handshake and yields the same `Request`: + + ```rust + let tls = acceptor.accept(tcp).await?; // your tokio_rustls TlsAcceptor + if let Some(request) = moq_rtmp::accept_stream(tls, peer).await? { + // authorize, then match on Request::Publish / Request::Play and accept it + } + ``` + +## Binary + +The `moq-rtmp` binary (needs the default `server` feature) has two modes. + +`serve` runs RTMP directly as a local relay, so MoQ subscribers (native or +browser) connect straight to this binary; RTMP players can also pull the same +broadcasts back out. It also exposes the `/certificate.sha256` endpoint browsers +need for self-signed `http://` origins, and can serve a static player directory +with `--dir`: + +```bash +moq-rtmp serve --server-bind [::]:443 --tls-generate localhost \ + --rtmp-listen 0.0.0.0:1935 --rtmp-prefix live/ +``` + +`publish` instead forwards every ingested broadcast out to a remote relay over +WebTransport (like `moq-srt publish` / `moq-hls import`): + +```bash +moq-rtmp publish --relay https://relay.example.com \ + --rtmp-listen 0.0.0.0:1935 --rtmp-prefix live/ +``` + +Either mode also accepts RTMPS alongside plaintext RTMP. Add `--rtmps-listen` +with a cert (`--rtmps-tls-cert` / `--rtmps-tls-key`, or `--rtmps-tls-generate` +for a throwaway self-signed cert), and OBS/ffmpeg can publish to `rtmps://`: + +```bash +moq-rtmp serve --server-bind [::]:443 --tls-generate localhost \ + --rtmp-listen 0.0.0.0:1935 \ + --rtmps-listen 0.0.0.0:1936 --rtmps-tls-cert cert.pem --rtmps-tls-key key.pem \ + --rtmp-prefix live/ +``` + +Feed either mode with any RTMP source. OBS: set the server to +`rtmp://127.0.0.1:1935/live` and the stream key to `cam0`. ffmpeg: + +```bash +# Lands at broadcast `live/cam0`. +ffmpeg -re -i input.mp4 -c copy -f flv rtmp://127.0.0.1:1935/live/cam0 + +# Enhanced RTMP (HEVC) lands the same way. +ffmpeg -re -i input.mp4 -c:v hevc -c:a aac -f flv rtmp://127.0.0.1:1935/live/cam0 +``` + +Play any broadcast back out over RTMP from a player (VLC, ffplay, mpv): + +```bash +# Pulls broadcast `live/cam0` (the same URL it was pushed to). +ffplay rtmp://127.0.0.1:1935/live/cam0 +``` + +## Routing + +Each connection's broadcast path is `/`, from the RTMP app and stream +key (`rtmp://host//`), falling back to just the app when the key is +empty. `--rtmp-prefix` is prepended to namespace a listener's streams. The same +routing applies to both directions, so the URL round-trips. First publisher on a +path wins (a second publish to a live path is rejected); plays don't claim a path, +so any number of players can pull the same broadcast at once, and a play waits for +the broadcast to be announced. + +## Auth + +The `run` entry point and the `moq-rtmp` binary are unauthenticated: anyone who +can reach the TCP port can publish or play, so gate them with a host firewall or a +private network. To authenticate, use the `Server` / `Request` API above and +verify each request in your accept loop (e.g. the stream key as a moq-token JWT, +the app as the broadcast path) before accepting it. That is the intended +integration point for a relay that already has JWT/path auth. diff --git a/rs/moq-rtmp/bin/moq-rtmp.rs b/rs/moq-rtmp/bin/moq-rtmp.rs new file mode 100644 index 000000000..1c49be356 --- /dev/null +++ b/rs/moq-rtmp/bin/moq-rtmp.rs @@ -0,0 +1,223 @@ +//! `moq-rtmp` binary. +//! +//! Bridges RTMP / enhanced-RTMP (OBS, ffmpeg, hardware encoders, and players like +//! VLC / ffplay / mpv) to MoQ. Each listener does both directions: a `publish` +//! ingests a stream in, a `play` pulls one back out (`rtmp://host//` +//! round-trips to the same path). Two binary modes wire up the MoQ side: +//! +//! - `serve` runs a local QUIC/WebTransport server so MoQ subscribers connect +//! straight to this binary (no separate relay needed). RTMP players can also +//! pull the same broadcasts back out. +//! - `publish` forwards every ingested broadcast out to a remote relay over +//! WebTransport, like `moq-srt publish` / `moq-hls import` / `moq-rtc` WHIP. +//! +//! A relay that wants in-process ingest/egress should instead depend on the +//! `moq-rtmp` library and call `moq_rtmp::run` against its own origin. + +mod serve; +mod web; + +use std::net::SocketAddr; +use std::path::PathBuf; + +use anyhow::Context; +use clap::{Args, Parser, Subcommand}; +use url::Url; + +#[derive(Parser, Clone)] +#[command(name = "moq-rtmp", version)] +struct Cli { + #[command(flatten)] + log: moq_native::Log, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Clone)] +enum Command { + /// Ingest RTMP and serve it directly as a local relay. + Serve { + /// The QUIC/WebTransport server configuration. + #[command(flatten)] + config: moq_native::ServerConfig, + + /// Optionally serve static files (e.g. a web player) from this directory. + #[arg(long)] + dir: Option, + + #[command(flatten)] + rtmp: RtmpArgs, + }, + /// Ingest RTMP and publish the broadcasts to a remote MoQ relay. + Publish { + /// The MoQ client configuration. + #[command(flatten)] + config: moq_native::ClientConfig, + + /// URL of the MoQ relay to publish into (e.g. `https://relay.example.com`). + /// + /// `https://` makes a WebTransport connection over QUIC. `http://` first + /// fetches `/certificate.sha256` for the TLS fingerprint (insecure). The + /// `?jwt=` query parameter supplies a moq-token-cli JWT; otherwise the + /// public path (if any) is used. + #[arg(long, env = "MOQ_RTMP_RELAY")] + relay: Url, + + #[command(flatten)] + rtmp: RtmpArgs, + }, +} + +/// CLI flags for the RTMP listener(s), converted into [`moq_rtmp::Config`]s. +#[derive(Args, Clone)] +struct RtmpArgs { + /// Address to listen on for plaintext RTMP ingest (`rtmp://`). RTMP's + /// well-known port is 1935. + #[arg(long = "rtmp-listen", env = "MOQ_RTMP_LISTEN", default_value = "[::]:1935")] + listen: SocketAddr, + + /// Prefix prepended to every ingested broadcast path (e.g. `live/`). + #[arg(long = "rtmp-prefix", env = "MOQ_RTMP_PREFIX", default_value = "")] + prefix: String, + + /// Also listen for RTMPS (RTMP over TLS, `rtmps://`) on this address, in + /// addition to plaintext RTMP. Requires a certificate via `--rtmps-tls-cert` + /// /`--rtmps-tls-key` or a self-signed `--rtmps-tls-generate`. RTMPS has no + /// well-known port; common choices are 443 or a custom one. + /// + /// The bundled RTMPS terminator reuses moq-native's certificate loader, which + /// is only built with the `noq` or `quinn` backend; these flags are absent in + /// other builds. + #[cfg(any(feature = "noq", feature = "quinn"))] + #[arg(long = "rtmps-listen", env = "MOQ_RTMPS_LISTEN")] + rtmps_listen: Option, + + /// PEM certificate chain for RTMPS. + #[cfg(any(feature = "noq", feature = "quinn"))] + #[arg(long = "rtmps-tls-cert", env = "MOQ_RTMPS_TLS_CERT")] + rtmps_cert: Option, + + /// PEM private key for RTMPS. + #[cfg(any(feature = "noq", feature = "quinn"))] + #[arg(long = "rtmps-tls-key", env = "MOQ_RTMPS_TLS_KEY")] + rtmps_key: Option, + + /// Or generate a self-signed RTMPS certificate for these hostnames (testing + /// only; clients must disable verification or pin the fingerprint). + #[cfg(any(feature = "noq", feature = "quinn"))] + #[arg(long = "rtmps-tls-generate", env = "MOQ_RTMPS_TLS_GENERATE", value_delimiter = ',')] + rtmps_generate: Vec, +} + +impl RtmpArgs { + /// Build the listener configs: always plaintext RTMP, plus RTMPS when + /// `--rtmps-listen` is set. Both share the `--rtmp-prefix`. + fn configs(&self) -> anyhow::Result> { + let mut plain = moq_rtmp::Config::default(); + plain.listen = Some(self.listen); + plain.prefix = self.prefix.clone(); + #[cfg_attr(not(any(feature = "noq", feature = "quinn")), allow(unused_mut))] + let mut configs = vec![plain]; + + #[cfg(any(feature = "noq", feature = "quinn"))] + if let Some(addr) = self.rtmps_listen { + // Reuse moq-native's cert loader (on-disk pair or self-signed). RTMPS + // has no ALPN convention, so advertise none. + let mut tls = moq_native::tls::Server::default(); + tls.cert = self.rtmps_cert.clone().into_iter().collect(); + tls.key = self.rtmps_key.clone().into_iter().collect(); + tls.generate = self.rtmps_generate.clone(); + let server_config = tls.server_config(vec![]).context("build RTMPS TLS config")?; + + let mut rtmps = moq_rtmp::Config::default(); + rtmps.listen = Some(addr); + rtmps.prefix = self.prefix.clone(); + rtmps.tls = Some(server_config); + configs.push(rtmps); + } + + Ok(configs) + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // moq-native pulls in `ring` somewhere transitively, so install the + // aws-lc-rs provider explicitly (mirrors moq-cli's main). + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("failed to install default crypto provider"); + + let cli = Cli::parse(); + cli.log.init()?; + + match cli.command { + Command::Serve { config, dir, rtmp } => run_serve(config, dir, rtmp.configs()?).await, + Command::Publish { config, relay, rtmp } => run_publish(config, relay, rtmp.configs()?).await, + } +} + +/// Run every configured RTMP/RTMPS listener against `origin`, failing if any of +/// them does. Stays pending while at least one listener is alive. +async fn run_ingest(origin: moq_net::OriginProducer, configs: Vec) -> anyhow::Result<()> { + use futures::StreamExt; + + let mut listeners: futures::stream::FuturesUnordered<_> = configs + .into_iter() + .map(|config| moq_rtmp::run(origin.clone(), config)) + .collect(); + + while let Some(res) = listeners.next().await { + res.context("rtmp ingest failed")?; + } + + Ok(()) +} + +/// Run a local QUIC/WebTransport server and ingest RTMP directly into it. +async fn run_serve( + config: moq_native::ServerConfig, + dir: Option, + rtmp: Vec, +) -> anyhow::Result<()> { + let web_bind = config.bind.clone().unwrap_or_else(|| "[::]:443".to_string()); + + let server = config.init().context("init moq server")?; + let web_tls = server.tls_info(); + + // RTMP publishes broadcasts into this origin; the server serves them out. + let origin = moq_net::Origin::random().produce(); + + tracing::info!(%web_bind, "moq-rtmp serving"); + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + tokio::select! { + res = serve::run(server, origin.clone()) => res.context("moq server failed"), + res = web::run(&web_bind, web_tls, dir) => res.context("web server failed"), + res = run_ingest(origin, rtmp) => res, + _ = tokio::signal::ctrl_c() => Ok(()), + } +} + +/// Ingest RTMP and forward every broadcast to a remote relay at `relay`. +async fn run_publish(config: moq_native::ClientConfig, relay: Url, rtmp: Vec) -> anyhow::Result<()> { + let client = config.init().context("init moq client")?; + + // RTMP publishes broadcasts into this origin; the client forwards them on. + let origin = moq_net::Origin::random().produce(); + let reconnect = client.with_publish(origin.consume()).reconnect(relay.clone()); + + tracing::info!(%relay, "moq-rtmp publishing"); + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + tokio::select! { + res = run_ingest(origin, rtmp) => res, + res = reconnect.closed() => res.context("relay connection failed"), + _ = tokio::signal::ctrl_c() => Ok(()), + } +} diff --git a/rs/moq-rtmp/bin/serve.rs b/rs/moq-rtmp/bin/serve.rs new file mode 100644 index 000000000..2b135791d --- /dev/null +++ b/rs/moq-rtmp/bin/serve.rs @@ -0,0 +1,38 @@ +//! Local QUIC/WebTransport server for `serve` mode. +//! +//! Accepts MoQ sessions and serves every broadcast the RTMP listener has +//! published into `origin`, so subscribers connect straight to this binary +//! instead of a separate relay. + +use moq_net::{OriginConsumer, OriginProducer}; + +/// Accept sessions and publish `origin`'s broadcasts to each subscriber. +pub async fn run(mut server: moq_native::Server, origin: OriginProducer) -> anyhow::Result<()> { + tracing::info!(addr = ?server.local_addr(), "listening"); + + let mut conn_id = 0; + while let Some(request) = server.accept().await { + let id = conn_id; + conn_id += 1; + + let consumer = origin.consume(); + tokio::spawn(async move { + if let Err(err) = serve_session(id, request, consumer).await { + tracing::warn!(%err, "session ended"); + } + }); + } + + anyhow::bail!("server stopped accepting connections") +} + +#[tracing::instrument("session", skip_all, fields(id))] +async fn serve_session(id: u64, request: moq_native::Request, consumer: OriginConsumer) -> anyhow::Result<()> { + // Blindly accept the session (WebTransport or QUIC), serving every ingested + // broadcast to the subscriber. + let session = request.with_publish(consumer).ok().await?; + + tracing::info!(id, "accepted session"); + + session.closed().await.map_err(Into::into) +} diff --git a/rs/moq-rtmp/bin/web.rs b/rs/moq-rtmp/bin/web.rs new file mode 100644 index 000000000..9492c9113 --- /dev/null +++ b/rs/moq-rtmp/bin/web.rs @@ -0,0 +1,64 @@ +//! HTTP sidecar for `serve` mode. +//! +//! Serves `/certificate.sha256` (the TLS fingerprint browsers fetch when +//! connecting to a self-signed `http://` origin) and, optionally, a static +//! directory for local development. Mirrors `moq-cli`'s web server. + +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use anyhow::Context; +use axum::handler::HandlerWithoutStateExt; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Router, http::Method, routing::get}; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; + +/// Serve the cert-fingerprint endpoint (and optional static `public` dir) on `bind`. +pub async fn run( + bind: &str, + tls_info: Arc>, + public: Option, +) -> anyhow::Result<()> { + let listen = tokio::net::lookup_host(bind) + .await + .context("invalid listen address")? + .next() + .context("invalid listen address")?; + + async fn handle_404() -> impl IntoResponse { + (StatusCode::NOT_FOUND, "Not found") + } + + let fingerprint_handler = move || async move { + tls_info + .read() + .expect("tls_info read lock poisoned") + .fingerprints + .first() + .expect("missing certificate") + .clone() + }; + + let mut app = Router::new() + .route("/certificate.sha256", get(fingerprint_handler)) + .layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])); + + if let Some(public) = public.as_ref() { + tracing::info!(public = %public.display(), "serving directory"); + + let public = ServeDir::new(public).not_found_service(handle_404.into_service()); + app = app.fallback_service(public); + } else { + app = app.fallback_service(handle_404.into_service()); + } + + // Dual-stack so the cert endpoint answers over IPv4 too, even on Windows + // where `[::]` is IPv6-only by default. + let listener = moq_native::bind::tcp(listen).context("failed to bind web listener")?; + let server = axum_server::from_tcp(listener)?; + server.serve(app.into_make_service()).await?; + + Ok(()) +} diff --git a/rs/moq-rtmp/src/error.rs b/rs/moq-rtmp/src/error.rs new file mode 100644 index 000000000..ae72c324b --- /dev/null +++ b/rs/moq-rtmp/src/error.rs @@ -0,0 +1,22 @@ +//! Errors for the RTMP ingest gateway. + +/// Errors produced while ingesting RTMP into MoQ. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// Error from the underlying moq-net transport (e.g. publishing into the origin). + #[error(transparent)] + Moq(#[from] moq_net::Error), + + /// I/O error from the RTMP listener or a connection. + #[error(transparent)] + Io(#[from] std::io::Error), + + /// Catch-all for ingest logic that reports via `anyhow` (the RTMP session and + /// the moq-mux demuxer surface their errors this way). + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +/// Result alias for the RTMP ingest gateway. +pub type Result = std::result::Result; diff --git a/rs/moq-rtmp/src/flv.rs b/rs/moq-rtmp/src/flv.rs new file mode 100644 index 000000000..e49fd57ba --- /dev/null +++ b/rs/moq-rtmp/src/flv.rs @@ -0,0 +1,249 @@ +//! Synthesize an FLV byte stream from RTMP audio/video messages. +//! +//! RTMP carries media as messages whose payloads are exactly FLV tag *bodies*: +//! an audio message (type 8) is an FLV AUDIODATA body, a video message (type 9) +//! is an FLV VIDEODATA body. moq-mux's [`flv::Import`](moq_mux::container::flv) +//! consumes a whole FLV byte stream (file header + framed tags), so to reuse it +//! we re-wrap each RTMP message in the FLV file/tag framing it expects rather +//! than demuxing RTMP ourselves. That demuxer handles both legacy (H.264 / AAC) +//! and enhanced-RTMP (HEVC / AV1 / VP9 / Opus / AC-3) payloads, so this framing +//! is codec-agnostic. +//! +//! See `moq-mux/src/container/flv` for the matching reader; the field layout +//! here mirrors what it parses (11-byte tag header, 24-bit + 8-bit extended +//! millisecond timestamp, trailing `PreviousTagSize`). +//! +//! The reverse direction (play / egress) uses [`TagReader`]: it splits the FLV +//! byte stream that [`moq_mux::container::flv::Export`] produces back into +//! individual tags, so each tag body can be sent to a player as an RTMP +//! audio/video message. + +use bytes::{Buf, BufMut, Bytes, BytesMut}; + +/// FLV tag type for audio data (matches moq-mux's `TAG_AUDIO`). +pub const TAG_AUDIO: u8 = 8; +/// FLV tag type for video data (matches moq-mux's `TAG_VIDEO`). +pub const TAG_VIDEO: u8 = 9; + +/// The 9-byte FLV file header plus its 4-byte `PreviousTagSize0`, emitted once +/// at the start of the synthesized stream before any tag. +/// +/// `46 4C 56` = "FLV", version 1, flags `0x05` (audio + video present), +/// data offset 9, then a zero `PreviousTagSize0`. moq-mux only checks the +/// "FLV" magic and the data offset, but we emit a spec-correct header anyway. +pub fn file_header() -> Bytes { + Bytes::from_static(&[ + b'F', b'L', b'V', // signature + 0x01, // version + 0x05, // flags: audio (bit 2) + video (bit 0) + 0x00, 0x00, 0x00, 0x09, // data offset (header length) + 0x00, 0x00, 0x00, 0x00, // PreviousTagSize0 + ]) +} + +/// Frame one RTMP message body as an FLV tag: the 11-byte tag header (type, +/// 24-bit data size, 24-bit + 8-bit extended timestamp, 24-bit stream id = 0), +/// the body, then the 4-byte `PreviousTagSize` trailer (header + body length). +/// +/// `timestamp` is the RTMP message timestamp in milliseconds; FLV splits it into +/// a low 24 bits and a high "extended" byte, exactly how moq-mux reassembles it. +pub fn tag(tag_type: u8, timestamp: u32, body: &[u8]) -> Bytes { + let data_size = body.len(); + let mut buf = BytesMut::with_capacity(11 + data_size + 4); + + buf.put_u8(tag_type); + // 24-bit data size, big-endian. + buf.put_u8((data_size >> 16) as u8); + buf.put_u8((data_size >> 8) as u8); + buf.put_u8(data_size as u8); + // Timestamp: low 24 bits big-endian, then the extended (most significant) byte. + buf.put_u8((timestamp >> 16) as u8); + buf.put_u8((timestamp >> 8) as u8); + buf.put_u8(timestamp as u8); + buf.put_u8((timestamp >> 24) as u8); + // Stream id is always 0. + buf.put_u8(0); + buf.put_u8(0); + buf.put_u8(0); + + buf.put_slice(body); + + // PreviousTagSize: the size of the tag header + body that precedes it. + buf.put_u32(11 + data_size as u32); + + buf.freeze() +} + +/// One FLV media tag pulled out of an [`Export`](moq_mux::container::flv::Export) +/// byte stream by [`TagReader`], ready to send as an RTMP message body. +pub struct Tag { + /// FLV tag type: [`TAG_AUDIO`] or [`TAG_VIDEO`]. + pub tag_type: u8, + /// Tag timestamp in milliseconds (the reassembled 24-bit + extended byte). + pub timestamp: u32, + /// The tag body, i.e. the bytes of the RTMP audio/video message to send. + pub body: Bytes, +} + +/// Splits an FLV byte stream back into its individual tags. +/// +/// The inverse of [`file_header`] + [`tag`]: feed the chunks +/// [`Export`](moq_mux::container::flv::Export) yields via [`push`](Self::push), +/// then drain whole tags with [`next`](Self::next). The leading FLV file header +/// is consumed and discarded; only the audio/video tags surface, each carrying +/// the body to forward as an RTMP message. +#[derive(Default)] +pub struct TagReader { + /// Bytes received but not yet parsed into a complete tag. + buf: BytesMut, + /// Set once the FLV file header has been consumed. + header_done: bool, +} + +impl TagReader { + /// Create an empty reader. + pub fn new() -> Self { + Self::default() + } + + /// Append a chunk of the FLV byte stream. + pub fn push(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// Pop the next complete tag, or `None` if more bytes are needed first. + /// + /// Errors only if the stream doesn't start with the FLV signature. + pub fn next(&mut self) -> anyhow::Result> { + if !self.header_done { + // File header: a 9-byte header whose last 4 bytes are the DataOffset, + // followed by the 4-byte PreviousTagSize0. + if self.buf.len() < FILE_HEADER_LEN { + return Ok(None); + } + anyhow::ensure!(&self.buf[0..3] == b"FLV", "not an FLV stream"); + let data_offset = u32::from_be_bytes([self.buf[5], self.buf[6], self.buf[7], self.buf[8]]) as usize; + let skip = data_offset + 4; + if self.buf.len() < skip { + return Ok(None); + } + self.buf.advance(skip); + self.header_done = true; + } + + // Tag header (11 bytes), body (DataSize bytes), then the 4-byte PreviousTagSize. + if self.buf.len() < TAG_HEADER_LEN { + return Ok(None); + } + let tag_type = self.buf[0]; + let data_size = ((self.buf[1] as usize) << 16) | ((self.buf[2] as usize) << 8) | (self.buf[3] as usize); + let timestamp = ((self.buf[4] as u32) << 16) + | ((self.buf[5] as u32) << 8) + | (self.buf[6] as u32) + | ((self.buf[7] as u32) << 24); + + if self.buf.len() < TAG_HEADER_LEN + data_size + 4 { + return Ok(None); + } + + self.buf.advance(TAG_HEADER_LEN); + let body = self.buf.split_to(data_size).freeze(); + self.buf.advance(4); // PreviousTagSize + + Ok(Some(Tag { + tag_type, + timestamp, + body, + })) + } +} + +/// Bytes in the FLV file header (signature, version, flags, DataOffset). +const FILE_HEADER_LEN: usize = 9; +/// Bytes in an FLV tag header (type, 24-bit size, timestamp, stream id). +const TAG_HEADER_LEN: usize = 11; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tag_reader_splits_header_and_tags() { + let mut reader = TagReader::new(); + reader.push(&file_header()); + // A header chunk often carries the sequence-header tag(s) too; feed two tags. + reader.push(&tag(TAG_VIDEO, 0, &[0x17, 0x00])); + reader.push(&tag(TAG_AUDIO, 0x01_02_03_04, &[0xaf, 0x00, 0x12])); + + let v = reader.next().unwrap().expect("video tag"); + assert_eq!(v.tag_type, TAG_VIDEO); + assert_eq!(v.timestamp, 0); + assert_eq!(&v.body[..], &[0x17, 0x00]); + + let a = reader.next().unwrap().expect("audio tag"); + assert_eq!(a.tag_type, TAG_AUDIO); + assert_eq!(a.timestamp, 0x01_02_03_04); + assert_eq!(&a.body[..], &[0xaf, 0x00, 0x12]); + + assert!(reader.next().unwrap().is_none()); + } + + #[test] + fn tag_reader_waits_for_a_whole_tag() { + let mut reader = TagReader::new(); + reader.push(&file_header()); + let full = tag(TAG_VIDEO, 7, &[1, 2, 3, 4, 5]); + // Feed everything but the last byte: no complete tag yet. + reader.push(&full[..full.len() - 1]); + assert!(reader.next().unwrap().is_none()); + // The final byte completes it. + reader.push(&full[full.len() - 1..]); + let t = reader.next().unwrap().expect("tag once complete"); + assert_eq!(t.timestamp, 7); + assert_eq!(&t.body[..], &[1, 2, 3, 4, 5]); + } + + #[test] + fn tag_reader_round_trips_export_style_framing() { + // Mirrors how Export emits: header chunk, then one tag per chunk. + let mut reader = TagReader::new(); + let mut header = BytesMut::new(); + header.extend_from_slice(&file_header()); + header.extend_from_slice(&tag(TAG_VIDEO, 0, b"seqhdr")); + reader.push(&header); + reader.push(&tag(TAG_VIDEO, 33, b"frame")); + + assert_eq!(&reader.next().unwrap().unwrap().body[..], b"seqhdr"); + assert_eq!(&reader.next().unwrap().unwrap().body[..], b"frame"); + assert!(reader.next().unwrap().is_none()); + } + + #[test] + fn tag_layout_roundtrips_timestamp_and_size() { + let body = [0x17, 0x00, 0x00, 0x00, 0x00, 0xde, 0xad]; + let ts = 0x01_02_03_04; // exercises the extended byte + let t = tag(TAG_VIDEO, ts, &body); + + assert_eq!(t[0], TAG_VIDEO); + // 24-bit data size. + let size = ((t[1] as usize) << 16) | ((t[2] as usize) << 8) | (t[3] as usize); + assert_eq!(size, body.len()); + // Timestamp = low 24 bits | extended byte << 24, matching the moq-mux reader. + let read = ((t[4] as u32) << 16) | ((t[5] as u32) << 8) | (t[6] as u32) | ((t[7] as u32) << 24); + assert_eq!(read, ts); + // Stream id is zero. + assert_eq!(&t[8..11], &[0, 0, 0]); + // Body follows the 11-byte header. + assert_eq!(&t[11..11 + body.len()], &body); + // Trailing PreviousTagSize = header + body. + let prev = u32::from_be_bytes([t[t.len() - 4], t[t.len() - 3], t[t.len() - 2], t[t.len() - 1]]); + assert_eq!(prev, 11 + body.len() as u32); + } + + #[test] + fn file_header_has_flv_magic() { + let h = file_header(); + assert_eq!(&h[0..3], b"FLV"); + assert_eq!(h.len(), 13); + } +} diff --git a/rs/moq-rtmp/src/lib.rs b/rs/moq-rtmp/src/lib.rs new file mode 100644 index 000000000..b5b5b7699 --- /dev/null +++ b/rs/moq-rtmp/src/lib.rs @@ -0,0 +1,67 @@ +//! RTMP / enhanced-RTMP gateway for MoQ: contribution ingest *and* egress. +//! +//! Runs an [RTMP](https://en.wikipedia.org/wiki/Real-Time_Messaging_Protocol) +//! server (the protocol OBS, ffmpeg, and most hardware encoders speak) and +//! bridges it to MoQ in both directions: +//! +//! - **Publish (ingest)**: a client (OBS, ffmpeg) pushes a stream in; we re-wrap +//! its audio/video messages as FLV tags, demux them with [`moq_mux`], and +//! publish the result into a [`moq_net::OriginProducer`] as ordinary MoQ +//! broadcasts. This is the contribution-ingest analogue of `moq-srt`, +//! `moq-hls`'s import, and `moq-rtc`'s WHIP. +//! - **Play (egress)**: a client (VLC, ffplay, mpv) pulls +//! `rtmp://host//`; we subscribe to that broadcast from a +//! [`moq_net::OriginConsumer`], mux it back to FLV with [`moq_mux`], and stream +//! the tags down as RTMP. The counterpart to `moq-hls`'s export. +//! +//! Both legacy RTMP (H.264 + AAC) and enhanced RTMP (E-RTMP: the HEVC, AV1, VP9, +//! Opus, and AC-3 FourCC payloads) are supported in each direction, because the +//! codec handling lives entirely in the [`moq_mux`] FLV demuxer/muxer; this crate +//! only translates the RTMP transport. Legacy players that speak only H.264 + AAC +//! will of course reject the E-RTMP codecs on the play path. +//! +//! Two entry points, depending on how much control you need over each request: +//! +//! - **[`run`]**: the unauthenticated convenience. Build a [`Config`] and hand it +//! plus an origin to [`run`]; it accepts every publisher and player and routes +//! by prefix + app/key (publishes into the origin, plays out of it). A relay +//! embeds this with `run(cluster.origin.clone(), config)`. +//! - **[`Server`] / [`Request`]**: bring your own auth. Loop on +//! [`Server::accept`], inspect [`Request::app`] / [`Request::stream_key`] (treat +//! the stream key as a token if you like), then match on the [`Request`]: accept +//! a [`Publish`] into an origin, or accept a [`Play`] out of one, at a path of +//! your choosing (or reject it). This is how an embedder (e.g. a relay verifying +//! a JWT and scoping the origin per token) plugs its policy in, with no +//! callback. It mirrors `moq-native`'s `Server` / `Request`. +//! +//! The bundled `moq-rtmp` binary serves the origin locally or forwards it to a +//! remote relay (those paths need the `server` feature). +//! +//! RTMPS (RTMP over TLS) is supported two ways: +//! +//! - **Let the gateway terminate TLS**: set [`Config::tls`] (or call +//! [`Server::with_tls`]) with a [`rustls::ServerConfig`], and the listener +//! speaks `rtmps://` with no other change. +//! - **Bring your own transport**: accept the connection and complete the TLS +//! handshake yourself (any [`Stream`]: a `tokio_rustls` stream, a custom +//! socket, a test pipe), then hand the established stream to [`accept_stream`]. +//! Useful when an existing TLS terminator, proxy, or non-TCP transport already +//! owns the socket. +//! +//! Pure Rust: the RTMP handshake, chunk codec, and session state machine come +//! from [`rml_rtmp`], with no librtmp or ffmpeg dependency. + +mod error; +mod flv; +mod listen; +mod server; + +pub use error::{Error, Result}; +pub use listen::{Config, run}; +pub use server::{Conn, Play, Publish, Request, Server, Stream, accept_stream}; + +/// Re-export of the `rustls` version this crate builds [`Config::tls`] against, +/// so consumers construct a matching [`rustls::ServerConfig`] (a major `rustls` +/// bump is a breaking change). Only available with the `server` feature. +#[cfg(feature = "server")] +pub use rustls; diff --git a/rs/moq-rtmp/src/listen.rs b/rs/moq-rtmp/src/listen.rs new file mode 100644 index 000000000..0a70db92b --- /dev/null +++ b/rs/moq-rtmp/src/listen.rs @@ -0,0 +1,244 @@ +//! RTMP listener, configuration, and stream-key routing. +//! +//! We run a TCP listener on RTMP's port, hand each connection to +//! [`crate::server`] for the handshake + session handling, and route each publish +//! or play to a broadcast path derived from its RTMP app and stream key. +//! +//! Routing: the usual OBS/ffmpeg/player URL is `rtmp://host[:1935]//`, +//! which arrives as app `` and stream key ``. The path is `/` +//! (just `` when the key is empty). The optional [`prefix`](Config::prefix) +//! is prepended so a single listener can namespace everything (e.g. prefix +//! `live/` + app `cam0` -> broadcast `live/cam0`). The same routing applies to +//! both directions: a publish ingests *into* that path, a play serves *from* it, +//! so the same URL round-trips (push to `rtmp://host/live/cam0`, pull it back from +//! the same URL). +//! +//! Auth: this listener is currently unauthenticated. Anyone who can reach the +//! TCP port can publish or play, so gate it with the host firewall / a private +//! network. Treating the stream key as a token (a moq-token JWT, as moq-edge +//! does) is the obvious next step. + +use std::collections::HashSet; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use moq_net::OriginProducer; + +use crate::Result; +use crate::server::{Request, Server}; + +/// RTMP ingest configuration. +/// +/// Construct via [`Config::default`] and set the fields you need, so new +/// options stay additive. Ingest is disabled (and [`run`] stays pending) unless +/// [`listen`](Config::listen) is set, letting an embedding relay run without +/// RTMP until it's configured. +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct Config { + /// Address to listen on for RTMP ingest (e.g. `0.0.0.0:1935`). When `None`, + /// RTMP ingest is disabled. + pub listen: Option, + + /// Prefix prepended to every ingested broadcast path. Lets one listener + /// namespace all of its streams (e.g. `live/`). + pub prefix: String, + + /// TLS configuration for RTMPS (RTMP over TLS). When set, the + /// [`listen`](Self::listen) address speaks RTMPS instead of plaintext RTMP, + /// so clients connect with `rtmps://`. Build it with + /// [`moq_native::tls::Server::server_config`] (pass an empty ALPN list) or + /// any [`rustls::ServerConfig`]. Leave `None` for plaintext. + /// + /// To serve both RTMP and RTMPS, run two listeners: call [`run`] once per + /// config (one with `tls`, one without) against a cloned origin. + #[cfg(feature = "server")] + pub tls: Option>, +} + +/// Run the RTMP listener until it fails, bridging each connection to `origin`: +/// publishers ingest into it as broadcasts, players are served broadcasts from it. +/// +/// This is the unauthenticated convenience entry point: it accepts every +/// publisher and player and routes by [`prefix`](Config::prefix) + app/key. Play +/// requests are served from `origin.consume()`, so anything in the origin (RTMP +/// ingests and otherwise) can be pulled back out over RTMP. To gate access (e.g. +/// verify the stream key as a JWT) or to scope the origin per client, drive +/// [`Server`] directly: loop on [`Server::accept`], match the [`Request`], and +/// call accept/reject after making your own decision. +/// +/// Stays pending forever (rather than resolving) when RTMP is disabled, so it +/// composes cleanly inside a `tokio::select!` alongside a relay's other +/// long-running tasks. +pub async fn run(origin: OriginProducer, config: Config) -> Result<()> { + let Some(listen) = config.listen else { + tracing::info!("RTMP ingest disabled (no listen address)"); + std::future::pending::<()>().await; + unreachable!("pending future never resolves"); + }; + + #[cfg_attr(not(feature = "server"), allow(unused_mut))] + let mut server = Server::bind(listen).await?; + + #[cfg(feature = "server")] + let tls = config.tls.is_some(); + #[cfg(not(feature = "server"))] + let tls = false; + + #[cfg(feature = "server")] + if let Some(tls) = config.tls.clone() { + server = server.with_tls(tls); + } + + tracing::info!(%listen, prefix = %config.prefix, tls, "RTMP ingest listening"); + + // Tracks which broadcast paths are currently being ingested so a second + // publisher on the same stream key is rejected (first-publisher-wins) instead + // of clobbering the live one. + let active = ActivePaths::default(); + let prefix = Arc::new(config.prefix); + // Players are served out of the same origin the publishers write into. + let consumer = origin.consume(); + + while let Some(request) = server.accept().await { + let prefix = prefix.clone(); + match request { + Request::Publish(publish) => { + let origin = origin.clone(); + let active = active.clone(); + // Each connection runs on its own task: `accept` pumps media for the + // whole connection lifetime, so handling it inline would serialize + // publishers. + tokio::spawn(async move { + let peer = publish.peer(); + let Some(path) = resolve_path(&prefix, publish.app(), publish.stream_key()) else { + tracing::warn!(%peer, "rejecting RTMP publish: no usable broadcast path"); + let _ = publish.reject("missing broadcast path (RTMP app/key)").await; + return; + }; + // Claim the path before accepting; the guard releases it when the + // connection task ends (success, error, or panic). + let Some(_guard) = active.claim(&path) else { + tracing::warn!(%peer, %path, "rejecting RTMP publish: path already being published"); + let _ = publish.reject("path already being published").await; + return; + }; + if let Err(err) = publish.accept(&origin, &path).await { + tracing::warn!(%peer, %path, %err, "RTMP ingest ended with error"); + } + }); + } + Request::Play(play) => { + let consumer = consumer.clone(); + // Many viewers can play the same path concurrently, so plays don't + // claim an `ActivePaths` slot. + tokio::spawn(async move { + let peer = play.peer(); + let Some(path) = resolve_path(&prefix, play.app(), play.stream_key()) else { + tracing::warn!(%peer, "rejecting RTMP play: no usable broadcast path"); + let _ = play.reject("missing broadcast path (RTMP app/key)").await; + return; + }; + if let Err(err) = play.accept(&consumer, &path).await { + tracing::warn!(%peer, %path, %err, "RTMP play ended with error"); + } + }); + } + } + } + + Err(anyhow::anyhow!("RTMP listener stopped accepting connections").into()) +} + +/// Derive a broadcast path from an RTMP app and stream key, applying `prefix`. +/// +/// `rtmp://host//` maps to `/`, falling back to just +/// the app (or just the key) when the other half is empty. Returns `None` when +/// there's nothing usable to route on. +pub(crate) fn resolve_path(prefix: &str, app: &str, key: &str) -> Option { + let app = app.trim_matches('/').trim(); + let key = key.trim_matches('/').trim(); + let name = match (app.is_empty(), key.is_empty()) { + (true, true) => return None, + (false, true) => app.to_string(), + (true, false) => key.to_string(), + (false, false) => format!("{app}/{key}"), + }; + Some(format!("{prefix}{name}")) +} + +/// The set of broadcast paths with a live ingest, used to reject duplicate +/// stream keys. Cheap to clone (shared `Arc`). +#[derive(Clone, Default)] +pub(crate) struct ActivePaths(Arc>>); + +impl ActivePaths { + /// Claim `path`, returning a guard that releases it on drop, or `None` if it + /// is already claimed. + pub(crate) fn claim(&self, path: &str) -> Option { + let mut set = self.0.lock().expect("active paths mutex poisoned"); + set.insert(path.to_string()).then(|| PathGuard { + paths: self.0.clone(), + path: path.to_string(), + }) + } +} + +/// Releases a claimed [`ActivePaths`] entry when dropped. +pub(crate) struct PathGuard { + paths: Arc>>, + path: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + self.paths + .lock() + .expect("active paths mutex poisoned") + .remove(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_and_key() { + assert_eq!(resolve_path("", "live", "cam0").as_deref(), Some("live/cam0")); + } + + #[test] + fn app_only() { + assert_eq!(resolve_path("", "cam0", "").as_deref(), Some("cam0")); + } + + #[test] + fn prefix_is_prepended() { + assert_eq!(resolve_path("live/", "cam0", "").as_deref(), Some("live/cam0")); + } + + #[test] + fn slashes_are_trimmed() { + assert_eq!(resolve_path("", "/live/", "/cam0/").as_deref(), Some("live/cam0")); + } + + #[test] + fn empty_is_rejected() { + assert_eq!(resolve_path("", "", ""), None); + } + + #[test] + fn active_paths_rejects_duplicates_and_releases_on_drop() { + let active = ActivePaths::default(); + + let guard = active.claim("live/cam0").expect("first claim succeeds"); + assert!(active.claim("live/cam0").is_none()); + let other = active.claim("live/cam1").expect("distinct path claims"); + + drop(guard); + assert!(active.claim("live/cam0").is_some()); + + drop(other); + } +} diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs new file mode 100644 index 000000000..d780114b7 --- /dev/null +++ b/rs/moq-rtmp/src/server.rs @@ -0,0 +1,1268 @@ +//! RTMP server: accept connections, and hand each pending request to the caller +//! as a [`Request`] to authorize. +//! +//! [`Server::accept`] runs the RTMP handshake and the connect command exchange +//! for each TCP connection (many concurrently, so a slow client doesn't block +//! others), then yields a [`Request`] once the client issues its `publish` or +//! `play` command. The caller inspects the app and stream key, makes an +//! authorization decision, and either: +//! +//! - **[`Request::Publish`]**: [`Publish::accept`] (ingest into an origin at a +//! path) or [`Publish::reject`]. This is the contribution path (OBS, ffmpeg). +//! - **[`Request::Play`]**: [`Play::accept`] (serve a broadcast from an origin +//! down to the player) or [`Play::reject`]. This is the egress path: a player +//! (VLC, ffplay, mpv) pulls `rtmp://host//` and we stream it back. +//! +//! This mirrors `moq-native`'s `Server` / `Request`, so the gateway stays +//! unopinionated about auth: the embedder (e.g. a relay verifying the stream key +//! as a JWT) owns that policy. +//! +//! RTMPS (RTMP over TLS): [`Server::with_tls`] makes the listener terminate TLS +//! before the RTMP handshake, so `rtmps://` clients work with no other change. +//! If you'd rather own the transport (custom TLS, a non-TCP socket, a test +//! pipe), accept the connection and complete any handshake yourself, then hand +//! the established stream to [`accept_stream`]; everything here is generic over +//! the [`Stream`] trait. + +use std::collections::VecDeque; +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +use futures::StreamExt; +use futures::future::BoxFuture; +use futures::stream::FuturesUnordered; +use moq_mux::container::flv::{Export as FlvExport, Import as FlvImport}; +use moq_net::{Broadcast, OriginConsumer, OriginProducer}; +use rml_rtmp::handshake::{Handshake, HandshakeProcessResult, PeerType}; +use rml_rtmp::sessions::{ServerSession, ServerSessionConfig, ServerSessionEvent, ServerSessionResult}; +use rml_rtmp::time::RtmpTimestamp; +use socket2::{SockRef, TcpKeepalive}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio::net::{TcpListener, TcpStream}; + +use crate::Result; +use crate::flv; + +/// Read buffer size for pulling RTMP chunk-stream bytes off the socket. +const READ_BUFFER: usize = 16 * 1024; + +/// How long a connection has to finish the handshake and issue its `publish` or +/// `play` before it is dropped. Bounds the lifetime (and socket / `pending` slot) +/// of a client that connects but never does either, so idle or half-open +/// connections can't accumulate without limit. With TLS this also covers the TLS +/// handshake. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +/// TCP keepalive idle period before the kernel starts probing a silent peer, and +/// the interval between probes. Once a connection is publishing or playing it can +/// block in a `read` indefinitely, so without keepalive a half-open connection (a +/// peer that vanished without sending a FIN/RST, e.g. a yanked network cable) +/// would pin its broadcast (and its first-publisher stream-key slot) forever. +/// Keepalive lets the kernel surface the dead peer as a read error, tearing the +/// session down. The values are generous enough not to disturb a healthy but +/// momentarily quiet connection. +const KEEPALIVE_IDLE: Duration = Duration::from_secs(30); +const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(10); + +/// A bidirectional byte stream carrying an RTMP session. +/// +/// A plaintext [`tokio::net::TcpStream`] for `rtmp://`, or a TLS stream you've +/// accepted for `rtmps://`. Implemented for every +/// `AsyncRead + AsyncWrite + Unpin + Send`, so [`accept_stream`] and +/// [`Request`] work over whatever transport you bring. +pub trait Stream: AsyncRead + AsyncWrite + Unpin + Send {} +impl Stream for T {} + +/// A connection accepted by [`Server`]: plaintext RTMP, or RTMPS over TLS. +/// +/// This is the stream type behind a [`Server`]-produced [`Request`] (hence +/// `Request`). Bring-your-own-transport callers using [`accept_stream`] +/// keep their own stream type instead. +pub enum Conn { + /// A plaintext TCP connection (`rtmp://`). + Plain(TcpStream), + + /// A TLS connection (`rtmps://`), established by [`Server::with_tls`]. Boxed + /// because a `TlsStream` is large relative to a bare `TcpStream`. + #[cfg(feature = "server")] + Tls(Box>), +} + +impl AsyncRead for Conn { + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + match self.get_mut() { + Conn::Plain(s) => Pin::new(s).poll_read(cx, buf), + #[cfg(feature = "server")] + Conn::Tls(s) => Pin::new(s).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for Conn { + fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + match self.get_mut() { + Conn::Plain(s) => Pin::new(s).poll_write(cx, buf), + #[cfg(feature = "server")] + Conn::Tls(s) => Pin::new(s).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Conn::Plain(s) => Pin::new(s).poll_flush(cx), + #[cfg(feature = "server")] + Conn::Tls(s) => Pin::new(s).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Conn::Plain(s) => Pin::new(s).poll_shutdown(cx), + #[cfg(feature = "server")] + Conn::Tls(s) => Pin::new(s).poll_shutdown(cx), + } + } +} + +/// An RTMP server that yields each connection's pending request as a [`Request`]. +/// +/// Build it with [`bind`](Self::bind), optionally enable RTMPS with +/// [`with_tls`](Self::with_tls), then loop on [`accept`](Self::accept). The +/// handshake and the connect exchange happen inside `accept`, so a [`Request`] is +/// only produced once a client actually wants to publish or play. +pub struct Server { + listener: TcpListener, + + /// When set, each accepted connection is TLS-terminated (RTMPS) before the + /// RTMP handshake. + #[cfg(feature = "server")] + tls: Option, + + /// In-flight handshakes; each resolves to a ready [`Request`], or `None` if + /// the connection closed or errored before issuing a publish or play. + pending: FuturesUnordered>>>, +} + +impl Server { + /// Bind an RTMP listener on `addr` (RTMP's well-known port is 1935). + pub async fn bind(addr: SocketAddr) -> Result { + let listener = TcpListener::bind(addr).await?; + Ok(Self { + listener, + #[cfg(feature = "server")] + tls: None, + pending: FuturesUnordered::new(), + }) + } + + /// Terminate TLS on every accepted connection, turning this into an RTMPS + /// listener (`rtmps://`). Pass a `rustls::ServerConfig` (e.g. from + /// [`moq_native::tls::Server::server_config`] with an empty ALPN list), or + /// `None` to leave it plaintext. + #[cfg(feature = "server")] + pub fn with_tls(mut self, tls: impl Into>>) -> Self { + self.tls = tls.into().map(tokio_rustls::TlsAcceptor::from); + self + } + + /// The local address the listener is bound to. + pub fn local_addr(&self) -> Result { + Ok(self.listener.local_addr()?) + } + + /// Wait for the next connection that wants to publish or play. + /// + /// New connections are accepted and handshaked concurrently; this returns the + /// next one to reach its `publish` or `play` command. Connections that close or + /// error before either are dropped without surfacing here. Returns `None` only + /// if the listener itself stops (it currently never does). + pub async fn accept(&mut self) -> Option> { + loop { + tokio::select! { + // A handshake finished: yield its request, or skip a dead connection. + Some(maybe) = self.pending.next(), if !self.pending.is_empty() => { + if let Some(request) = maybe { + return Some(request); + } + } + // A new TCP connection: start its (TLS +) handshake concurrently. + res = self.listener.accept() => match res { + Ok((stream, peer)) => { + configure_socket(&stream, peer); + #[cfg(feature = "server")] + let tls = self.tls.clone(); + self.pending.push(Box::pin(async move { + // The TLS handshake (if any) and the RTMP handshake share one + // budget, so a client that stalls either is dropped. + let outcome = tokio::time::timeout(REQUEST_TIMEOUT, async move { + #[cfg(feature = "server")] + let conn = match tls { + Some(acceptor) => Conn::Tls(Box::new( + acceptor + .accept(stream) + .await + .map_err(|e| anyhow::anyhow!("rtmps tls handshake: {e}"))?, + )), + None => Conn::Plain(stream), + }; + #[cfg(not(feature = "server"))] + let conn = Conn::Plain(stream); + accept_until_request(conn, peer).await + }) + .await; + match outcome { + Ok(Ok(request)) => request, + Ok(Err(err)) => { + tracing::warn!(%peer, %err, "RTMP connection closed before publish/play"); + None + } + Err(_) => { + tracing::warn!(%peer, "RTMP connection did not publish or play before timeout"); + None + } + } + })); + } + Err(err) => { + // A failed accept must not take the listener down; back off so a + // persistent error doesn't busy-spin. + tracing::warn!(%err, "failed to accept RTMP connection; continuing"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + }, + } + } + } +} + +/// Tune a freshly accepted RTMP socket: Nagle off for latency, keepalive on so a +/// dead peer is reaped rather than pinning a broadcast forever. +/// +/// Both are best-effort: a failure to set either is logged and ignored rather +/// than dropping an otherwise healthy connection. +fn configure_socket(stream: &TcpStream, peer: SocketAddr) { + // Nagle off: RTMP is latency-sensitive and we write whole packets. + if let Err(err) = stream.set_nodelay(true) { + tracing::debug!(%peer, %err, "failed to set TCP_NODELAY"); + } + let keepalive = TcpKeepalive::new() + .with_time(KEEPALIVE_IDLE) + .with_interval(KEEPALIVE_INTERVAL); + if let Err(err) = SockRef::from(stream).set_tcp_keepalive(&keepalive) { + tracing::debug!(%peer, %err, "failed to set TCP keepalive"); + } +} + +/// Run the RTMP handshake and connect exchange on an already-established byte +/// stream, yielding the pending publish or play as a [`Request`]. +/// +/// The bring-your-own-transport entry point: accept the connection (and, for +/// `rtmps://`, complete the TLS handshake) yourself, then hand the stream here. +/// `peer` is the remote address, used for logging and [`Request::peer`]. +/// +/// Returns `Ok(None)` if the client disconnects before issuing `publish` or +/// `play`. Unlike [`Server`], this applies no timeout: wrap the call in +/// [`tokio::time::timeout`] to bound how long a connected-but-idle client can +/// hold the task. +pub async fn accept_stream(stream: S, peer: SocketAddr) -> Result>> { + Ok(accept_until_request(stream, peer).await?) +} + +/// What an accepted RTMP connection wants: to contribute media ([`Publish`]) or +/// to view it ([`Play`]). +/// +/// Yielded by [`Server::accept`] / [`accept_stream`] once the client issues its +/// `publish` or `play` command. Inspect [`app`](Self::app) / +/// [`stream_key`](Self::stream_key), then match to authorize the right +/// direction. Dropping it without accepting or rejecting closes the connection. +/// +/// `S` is the underlying stream: [`Conn`] for a [`Server`]-produced request, or +/// your own transport when built via [`accept_stream`]. +#[non_exhaustive] +pub enum Request { + /// A client pushing media in (OBS, ffmpeg). Ingest it with [`Publish::accept`]. + Publish(Publish), + /// A client pulling media out (VLC, ffplay, mpv). Serve it with [`Play::accept`]. + Play(Play), +} + +impl Request { + /// The RTMP app name (the path component of `rtmp://host//`). + pub fn app(&self) -> &str { + match self { + Request::Publish(r) => r.app(), + Request::Play(r) => r.app(), + } + } + + /// The RTMP stream key (the final component of `rtmp://host//`). + pub fn stream_key(&self) -> &str { + match self { + Request::Publish(r) => r.stream_key(), + Request::Play(r) => r.stream_key(), + } + } + + /// The remote peer address. + pub fn peer(&self) -> SocketAddr { + match self { + Request::Publish(r) => r.peer(), + Request::Play(r) => r.peer(), + } + } +} + +/// A pending RTMP publish (contribution), waiting on the caller to authorize it. +/// +/// Inspect [`app`](Self::app) and [`stream_key`](Self::stream_key) (an +/// `rtmp://host//` URL splits into these), then either +/// [`accept`](Self::accept) the publish into an origin at a chosen broadcast +/// path or [`reject`](Self::reject) it. Dropping it without either closes the +/// connection. +/// +/// `S` is the underlying stream: [`Conn`] for a [`Server`]-produced request, or +/// your own transport when built via [`accept_stream`]. +pub struct Publish { + stream: S, + session: ServerSession, + /// The `rml_rtmp` request id for the pending publish, replied to on accept/reject. + request_id: u32, + /// Session results produced alongside the publish command, processed once the + /// publish is accepted. + work: VecDeque, + app: String, + stream_key: String, + peer: SocketAddr, +} + +impl Publish { + /// The RTMP app name (the path component of `rtmp://host//`). + pub fn app(&self) -> &str { + &self.app + } + + /// The RTMP stream key (the final component of `rtmp://host//`). + /// + /// Conventionally a publish secret; an embedder can treat it as a token (e.g. + /// a moq-token JWT) to authenticate the publish. + pub fn stream_key(&self) -> &str { + &self.stream_key + } + + /// The remote peer address. + pub fn peer(&self) -> SocketAddr { + self.peer + } + + /// Accept the publish: announce a broadcast at `path` in `origin` and pump the + /// RTMP media into it until the client disconnects. + /// + /// `origin` is whatever the caller wants the media published into (e.g. a + /// relay's shared origin, optionally re-rooted/scoped per the authenticated + /// token). This future resolves when the connection ends, so callers usually + /// run it on its own task. + pub async fn accept(mut self, origin: &OriginProducer, path: &str) -> Result<()> { + // Reserve the broadcast path before telling the client the publish + // succeeded: if `path` is already being published (or otherwise refused by + // the origin), reject cleanly instead of accepting and then dropping the + // connection a moment later. + let mut publisher = match Publisher::new(origin, path) { + Ok(publisher) => publisher, + Err(err) => { + tracing::warn!(peer = %self.peer, %path, %err, "rejecting RTMP publish: broadcast unavailable"); + let results = self + .session + .reject_request(self.request_id, "NetStream.Publish.Denied", "broadcast unavailable") + .map_err(|e| anyhow::anyhow!("rtmp reject publish: {e:?}"))?; + for result in self.work.drain(..).chain(results) { + if let ServerSessionResult::OutboundResponse(packet) = result { + self.stream.write_all(&packet.bytes).await?; + } + } + return Ok(()); + } + }; + + let results = self + .session + .accept_request(self.request_id) + .map_err(|e| anyhow::anyhow!("rtmp accept publish: {e:?}"))?; + self.work.extend(results); + + tracing::info!(peer = %self.peer, %path, "rtmp publish accepted"); + + let result = pump( + &mut self.stream, + &mut self.session, + &mut self.work, + &mut publisher, + self.peer, + ) + .await; + + // Flush the importer so the final groups close cleanly before unannouncing. + if let Err(err) = publisher.finish() { + tracing::debug!(peer = %self.peer, %err, "error finishing RTMP publish"); + } + + Ok(result?) + } + + /// Reject the publish, sending `reason` back to the client as the + /// `NetStream.Publish.Denied` description, then close the connection. + pub async fn reject(mut self, reason: &str) -> Result<()> { + let results = self + .session + .reject_request(self.request_id, "NetStream.Publish.Denied", reason) + .map_err(|e| anyhow::anyhow!("rtmp reject publish: {e:?}"))?; + + // Flush any pending writes plus the rejection so it reaches the client. + for result in self.work.drain(..).chain(results) { + if let ServerSessionResult::OutboundResponse(packet) = result { + self.stream.write_all(&packet.bytes).await?; + } + } + tracing::debug!(peer = %self.peer, %reason, "rtmp publish rejected"); + Ok(()) + } +} + +/// A pending RTMP play (egress), waiting on the caller to authorize it. +/// +/// The viewing counterpart of [`Publish`]: inspect [`app`](Self::app) / +/// [`stream_key`](Self::stream_key), then [`accept`](Self::accept) to serve a +/// broadcast from an origin down to the player, or [`reject`](Self::reject) it. +/// Dropping it without either closes the connection. +/// +/// `S` is the underlying stream: [`Conn`] for a [`Server`]-produced request, or +/// your own transport when built via [`accept_stream`]. +pub struct Play { + stream: S, + session: ServerSession, + /// The `rml_rtmp` request id for the pending play, replied to on accept/reject. + request_id: u32, + /// The RTMP message stream id to address outbound media at (from the `play`). + stream_id: u32, + /// Session results produced alongside the play command, processed on accept. + work: VecDeque, + app: String, + stream_key: String, + peer: SocketAddr, +} + +impl Play { + /// The RTMP app name (the path component of `rtmp://host//`). + pub fn app(&self) -> &str { + &self.app + } + + /// The RTMP stream key (the final component of `rtmp://host//`). + /// + /// As with a publish, an embedder can treat this as a token to authorize the + /// viewer. + pub fn stream_key(&self) -> &str { + &self.stream_key + } + + /// The remote peer address. + pub fn peer(&self) -> SocketAddr { + self.peer + } + + /// Accept the play: subscribe to the broadcast at `path` in `origin`, mux it + /// to FLV, and stream the tags down to the player until either side ends. + /// + /// Waits for the broadcast to be announced (so a player can connect slightly + /// before the publisher), cancelling cleanly if the viewer disconnects first. + /// This future resolves when playback ends, so callers usually run it on its + /// own task. + pub async fn accept(mut self, origin: &OriginConsumer, path: &str) -> Result<()> { + // Tell the client playback is starting (Play.Reset / Play.Start + StreamBegin). + let results = self + .session + .accept_request(self.request_id) + .map_err(|e| anyhow::anyhow!("rtmp accept play: {e:?}"))?; + self.work.extend(results); + flush_outbound(&mut self.stream, &mut self.work).await?; + + tracing::info!(peer = %self.peer, %path, "rtmp play accepted"); + + // Wait for the broadcast, but abandon the wait if the viewer hangs up. Feed + // the client's bytes through the session (not discard them) so its + // deserializer stays in sync for everything `play_pump` parses next. + let broadcast = tokio::select! { + biased; + res = feed_input(&mut self.stream, &mut self.session, &mut self.work) => { + res?; + tracing::debug!(peer = %self.peer, %path, "viewer disconnected before play started"); + return Ok(()); + } + broadcast = origin.announced_broadcast(path) => broadcast, + }; + let Some(broadcast) = broadcast else { + tracing::debug!(peer = %self.peer, %path, "play broadcast unavailable"); + return Ok(()); + }; + + let mut export = FlvExport::new(broadcast).map_err(|e| anyhow::anyhow!("init FLV export: {e}"))?; + let result = play_pump( + &mut self.stream, + &mut self.session, + &mut self.work, + &mut export, + self.stream_id, + self.peer, + ) + .await; + + tracing::debug!(peer = %self.peer, %path, "rtmp play ended"); + result + } + + /// Reject the play, sending `reason` back to the client as the + /// `NetStream.Play.Failed` description, then close the connection. + pub async fn reject(mut self, reason: &str) -> Result<()> { + let results = self + .session + .reject_request(self.request_id, "NetStream.Play.Failed", reason) + .map_err(|e| anyhow::anyhow!("rtmp reject play: {e:?}"))?; + + for result in self.work.drain(..).chain(results) { + if let ServerSessionResult::OutboundResponse(packet) = result { + self.stream.write_all(&packet.bytes).await?; + } + } + tracing::debug!(peer = %self.peer, %reason, "rtmp play rejected"); + Ok(()) + } +} + +/// Run one connection's handshake and connect exchange, returning a [`Request`] +/// once the client issues `publish` or `play` (or `None` if it disconnects first). +async fn accept_until_request(mut stream: S, peer: SocketAddr) -> anyhow::Result>> { + let remaining = run_handshake(&mut stream, peer).await?; + + let (mut session, initial) = + ServerSession::new(ServerSessionConfig::new()).map_err(|e| anyhow::anyhow!("rtmp session init: {e:?}"))?; + let mut work: VecDeque = VecDeque::from(initial); + + // Any RTMP bytes bundled with the final handshake packet. + if !remaining.is_empty() { + let results = session + .handle_input(&remaining) + .map_err(|e| anyhow::anyhow!("rtmp handle_input: {e:?}"))?; + work.extend(results); + } + + let mut buffer = [0u8; READ_BUFFER]; + loop { + while let Some(result) = work.pop_front() { + match result { + ServerSessionResult::OutboundResponse(packet) => { + stream.write_all(&packet.bytes).await?; + } + ServerSessionResult::RaisedEvent(event) => match event { + // Accept every connect; authorization happens at publish/play time. + ServerSessionEvent::ConnectionRequested { request_id, app_name } => { + tracing::debug!(%peer, %app_name, "rtmp connect"); + let results = session + .accept_request(request_id) + .map_err(|e| anyhow::anyhow!("rtmp accept connect: {e:?}"))?; + work.extend(results); + } + // The client wants to publish: hand control back to the caller. + ServerSessionEvent::PublishStreamRequested { + request_id, + app_name, + stream_key, + .. + } => { + return Ok(Some(Request::Publish(Publish { + stream, + session, + request_id, + work, + app: app_name, + stream_key, + peer, + }))); + } + // The client wants to play: hand control back to the caller. + ServerSessionEvent::PlayStreamRequested { + request_id, + app_name, + stream_key, + stream_id, + .. + } => { + return Ok(Some(Request::Play(Play { + stream, + session, + request_id, + stream_id, + work, + app: app_name, + stream_key, + peer, + }))); + } + other => tracing::trace!(%peer, ?other, "ignoring RTMP event before publish/play"), + }, + ServerSessionResult::UnhandleableMessageReceived(_) => { + tracing::trace!(%peer, "ignoring unhandleable RTMP message"); + } + } + } + + let n = stream.read(&mut buffer).await?; + if n == 0 { + return Ok(None); + } + let results = session + .handle_input(&buffer[..n]) + .map_err(|e| anyhow::anyhow!("rtmp handle_input: {e:?}"))?; + work.extend(results); + } +} + +/// Pump RTMP media into the publisher until the client disconnects or finishes. +async fn pump( + stream: &mut S, + session: &mut ServerSession, + work: &mut VecDeque, + publisher: &mut Publisher, + peer: SocketAddr, +) -> anyhow::Result<()> { + let mut buffer = [0u8; READ_BUFFER]; + loop { + let mut finished = false; + while let Some(result) = work.pop_front() { + match result { + ServerSessionResult::OutboundResponse(packet) => { + stream.write_all(&packet.bytes).await?; + } + ServerSessionResult::RaisedEvent(event) => match event { + // A frame that fails to demux is dropped, not fatal: the importer + // consumes whole tags atomically, so one bad frame doesn't desync + // the stream, and tearing down a live publish over it would be worse. + ServerSessionEvent::AudioDataReceived { data, timestamp, .. } => { + if let Err(err) = publisher.push(flv::TAG_AUDIO, timestamp.value, &data) { + tracing::warn!(%peer, %err, "dropping RTMP audio frame that failed to demux"); + } + } + ServerSessionEvent::VideoDataReceived { data, timestamp, .. } => { + if let Err(err) = publisher.push(flv::TAG_VIDEO, timestamp.value, &data) { + tracing::warn!(%peer, %err, "dropping RTMP video frame that failed to demux"); + } + } + ServerSessionEvent::PublishStreamFinished { .. } => finished = true, + // onMetaData and other script data: the FLV importer reads codec + // config from the sequence headers, so metadata isn't forwarded. + ServerSessionEvent::StreamMetadataChanged { .. } => {} + other => tracing::trace!(%peer, ?other, "ignoring RTMP event"), + }, + ServerSessionResult::UnhandleableMessageReceived(_) => { + tracing::trace!(%peer, "ignoring unhandleable RTMP message"); + } + } + } + if finished { + break; + } + + let n = stream.read(&mut buffer).await?; + if n == 0 { + break; + } + let results = session + .handle_input(&buffer[..n]) + .map_err(|e| anyhow::anyhow!("rtmp handle_input: {e:?}"))?; + work.extend(results); + } + + tracing::debug!(%peer, "rtmp connection closed"); + Ok(()) +} + +/// Stream a broadcast to an RTMP player until the broadcast ends or the viewer +/// stops. +/// +/// Pulls FLV from `export`, splits it back into tags, and sends each as an RTMP +/// audio/video message; concurrently it services client input (acknowledgements, +/// pings, `deleteStream`) so a long playback stays healthy. The read and write +/// halves run independently, so media keeps flowing regardless of when the viewer +/// next sends anything. +async fn play_pump( + stream: &mut S, + session: &mut ServerSession, + work: &mut VecDeque, + export: &mut FlvExport, + stream_id: u32, + peer: SocketAddr, +) -> Result<()> { + let (mut reader, mut writer) = tokio::io::split(stream); + let mut tags = flv::TagReader::new(); + let mut buffer = [0u8; READ_BUFFER]; + + loop { + // Flush responses queued by the last batch of client input. + while let Some(result) = work.pop_front() { + match result { + ServerSessionResult::OutboundResponse(packet) => writer.write_all(&packet.bytes).await?, + ServerSessionResult::RaisedEvent(ServerSessionEvent::PlayStreamFinished { .. }) => { + tracing::debug!(%peer, "viewer stopped playback"); + return Ok(()); + } + ServerSessionResult::RaisedEvent(other) => { + tracing::trace!(%peer, ?other, "ignoring RTMP event during play") + } + ServerSessionResult::UnhandleableMessageReceived(_) => {} + } + } + + tokio::select! { + // Media from the broadcast: split into tags and send each one down. + chunk = export.next() => match chunk? { + Some(bytes) => { + tags.push(&bytes); + while let Some(tag) = tags.next()? { + let ts = RtmpTimestamp::new(tag.timestamp); + let packet = match tag.tag_type { + flv::TAG_VIDEO => session.send_video_data(stream_id, tag.body, ts, false), + flv::TAG_AUDIO => session.send_audio_data(stream_id, tag.body, ts, false), + _ => continue, + } + .map_err(|e| anyhow::anyhow!("rtmp send media: {e:?}"))?; + writer.write_all(&packet.bytes).await?; + } + } + // Broadcast ended: tell the player and finish. + None => { + let packet = session + .finish_playing(stream_id) + .map_err(|e| anyhow::anyhow!("rtmp finish play: {e:?}"))?; + writer.write_all(&packet.bytes).await?; + return Ok(()); + } + }, + // Client input: feed the session so it can ack / tear down. + res = reader.read(&mut buffer) => { + let n = res?; + if n == 0 { + return Ok(()); + } + let results = session + .handle_input(&buffer[..n]) + .map_err(|e| anyhow::anyhow!("rtmp handle_input: {e:?}"))?; + work.extend(results); + } + } + } +} + +/// Write every queued [`OutboundResponse`](ServerSessionResult::OutboundResponse) +/// to the client, dropping the other result kinds. +async fn flush_outbound(stream: &mut S, work: &mut VecDeque) -> anyhow::Result<()> { + for result in work.drain(..) { + if let ServerSessionResult::OutboundResponse(packet) = result { + stream.write_all(&packet.bytes).await?; + } + } + Ok(()) +} + +/// Feed client bytes through the session while we wait for the broadcast, until +/// the viewer hangs up or stops. +/// +/// Returns `Ok(())` when the client closes the connection (EOF) or issues a +/// `play` teardown, so the caller can abandon the play. Crucially it does *not* +/// discard the bytes: RTMP is a single continuous chunk stream, so skipping any +/// bytes would desynchronize the session's deserializer for everything +/// [`play_pump`] parses afterwards. Pre-playback the client's control messages +/// (window ack, set buffer length) need no reply, so any responses are left +/// queued in `work` for `play_pump` to flush rather than written here. The only +/// await is the read, so dropping this future when the broadcast arrives is +/// cancellation-safe (no half-consumed read). +async fn feed_input( + stream: &mut S, + session: &mut ServerSession, + work: &mut VecDeque, +) -> anyhow::Result<()> { + let mut buffer = [0u8; READ_BUFFER]; + loop { + let n = stream.read(&mut buffer).await?; + if n == 0 { + return Ok(()); + } + let results = session + .handle_input(&buffer[..n]) + .map_err(|e| anyhow::anyhow!("rtmp handle_input: {e:?}"))?; + // The viewer tore down the play before media started: stop waiting. + let stopped = results.iter().any(|r| { + matches!( + r, + ServerSessionResult::RaisedEvent(ServerSessionEvent::PlayStreamFinished { .. }) + ) + }); + work.extend(results); + if stopped { + return Ok(()); + } + } +} + +/// Perform the RTMP server handshake, returning any leftover bytes that followed +/// the client's final handshake packet (the start of the chunk stream). +async fn run_handshake(stream: &mut S, peer: SocketAddr) -> anyhow::Result> { + let mut handshake = Handshake::new(PeerType::Server); + let p0_p1 = handshake + .generate_outbound_p0_and_p1() + .map_err(|e| anyhow::anyhow!("rtmp handshake p0/p1: {e:?}"))?; + stream.write_all(&p0_p1).await?; + + let mut buffer = [0u8; 4096]; + loop { + let n = stream.read(&mut buffer).await?; + if n == 0 { + anyhow::bail!("peer {peer} closed during handshake"); + } + + match handshake + .process_bytes(&buffer[..n]) + .map_err(|e| anyhow::anyhow!("rtmp handshake: {e:?}"))? + { + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await?; + } + } + HandshakeProcessResult::Completed { + response_bytes, + remaining_bytes, + } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await?; + } + tracing::debug!(%peer, "rtmp handshake complete"); + return Ok(remaining_bytes); + } + } + } +} + +/// An active publish: the moq-mux FLV importer, which owns the +/// [`BroadcastProducer`](moq_net::BroadcastProducer) it publishes into. The +/// origin holds a [`BroadcastConsumer`](moq_net::BroadcastConsumer) of it, so the +/// broadcast stays announced while this importer (the last producer handle) is +/// alive; dropping it closes and unannounces the broadcast. +struct Publisher { + importer: FlvImport, +} + +impl Publisher { + /// Open a broadcast at `path` and prime the importer with the FLV file + /// header, so subsequent tags decode against an initialized demuxer. + fn new(origin: &OriginProducer, path: &str) -> anyhow::Result { + let mut broadcast = Broadcast::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast)?; + let mut importer = FlvImport::new(broadcast.clone(), catalog); + + anyhow::ensure!( + origin.publish_broadcast(path, broadcast.consume()), + "broadcast '{path}' could not be published" + ); + + // Feed the FLV file header once up front; media tags follow per message. + importer.decode(&mut flv::file_header())?; + + Ok(Self { importer }) + } + + /// Re-wrap one RTMP audio/video message body as an FLV tag and demux it. + fn push(&mut self, tag_type: u8, timestamp: u32, body: &[u8]) -> anyhow::Result<()> { + // FLV's tag DataSize is 24-bit. A larger body would truncate, declaring a + // wrong size that desyncs the demuxer on the next tag. Drop it instead. + anyhow::ensure!( + body.len() <= 0xFF_FFFF, + "RTMP message body {} exceeds FLV's 24-bit tag size limit", + body.len() + ); + self.importer.decode(&mut flv::tag(tag_type, timestamp, body)) + } + + /// Flush any buffered media and close out the broadcast's open groups. + fn finish(&mut self) -> anyhow::Result<()> { + self.importer.finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rml_rtmp::sessions::{ + ClientSession, ClientSessionConfig, ClientSessionEvent, ClientSessionResult, PublishRequestType, + }; + + /// What the test client asks for once connected. + #[derive(Clone, Copy)] + enum ClientMode { + Publish, + Play, + } + + /// Drive a real RTMP client over an already-connected `stream` through + /// handshake -> connect(`live`) -> publish/play(`cam0`), pumping until aborted + /// by the test. Generic over the transport so the same client exercises both + /// plaintext RTMP and RTMPS. + async fn run_client(mut stream: S, mode: ClientMode) { + // Handshake. + let mut handshake = Handshake::new(PeerType::Client); + stream + .write_all(&handshake.generate_outbound_p0_and_p1().unwrap()) + .await + .unwrap(); + let mut buffer = [0u8; 4096]; + let remaining = loop { + let n = stream.read(&mut buffer).await.unwrap(); + match handshake.process_bytes(&buffer[..n]).unwrap() { + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await.unwrap(); + } + } + HandshakeProcessResult::Completed { + response_bytes, + remaining_bytes, + } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await.unwrap(); + } + break remaining_bytes; + } + } + }; + + let (mut session, initial) = ClientSession::new(ClientSessionConfig::new()).unwrap(); + let mut work: VecDeque = VecDeque::from(initial); + if !remaining.is_empty() { + work.extend(session.handle_input(&remaining).unwrap()); + } + work.push_back(session.request_connection("live".to_string()).unwrap()); + + loop { + while let Some(result) = work.pop_front() { + match result { + ClientSessionResult::OutboundResponse(packet) => { + stream.write_all(&packet.bytes).await.unwrap(); + } + // Once connected, ask to publish or play; the command is sent + // automatically as the createStream round trip completes. + ClientSessionResult::RaisedEvent(ClientSessionEvent::ConnectionRequestAccepted) => { + let result = match mode { + ClientMode::Publish => session + .request_publishing("cam0".to_string(), PublishRequestType::Live) + .unwrap(), + ClientMode::Play => session.request_playback("cam0".to_string()).unwrap(), + }; + work.push_back(result); + } + _ => {} + } + } + let n = match stream.read(&mut buffer).await { + Ok(n) => n, + Err(_) => return, + }; + if n == 0 { + return; + } + match session.handle_input(&buffer[..n]) { + Ok(results) => work.extend(results), + Err(_) => return, + } + } + } + + /// A received RTMP media message: `is_video` and the message body. + type Media = (bool, bytes::Bytes); + + /// Drive an RTMP play client over `stream` through handshake -> connect(`live`) + /// -> play(`cam0`), collecting media messages until `want` have arrived. + async fn play_client_collect(mut stream: S, want: usize) -> Vec { + // Handshake. + let mut handshake = Handshake::new(PeerType::Client); + stream + .write_all(&handshake.generate_outbound_p0_and_p1().unwrap()) + .await + .unwrap(); + let mut buffer = [0u8; 4096]; + let remaining = loop { + let n = stream.read(&mut buffer).await.unwrap(); + match handshake.process_bytes(&buffer[..n]).unwrap() { + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await.unwrap(); + } + } + HandshakeProcessResult::Completed { + response_bytes, + remaining_bytes, + } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await.unwrap(); + } + break remaining_bytes; + } + } + }; + + let (mut session, initial) = ClientSession::new(ClientSessionConfig::new()).unwrap(); + let mut work: VecDeque = VecDeque::from(initial); + if !remaining.is_empty() { + work.extend(session.handle_input(&remaining).unwrap()); + } + work.push_back(session.request_connection("live".to_string()).unwrap()); + + let mut media = Vec::new(); + loop { + while let Some(result) = work.pop_front() { + match result { + ClientSessionResult::OutboundResponse(packet) => { + stream.write_all(&packet.bytes).await.unwrap(); + } + ClientSessionResult::RaisedEvent(ClientSessionEvent::ConnectionRequestAccepted) => { + work.push_back(session.request_playback("cam0".to_string()).unwrap()); + } + ClientSessionResult::RaisedEvent(ClientSessionEvent::VideoDataReceived { data, .. }) => { + media.push((true, data)); + } + ClientSessionResult::RaisedEvent(ClientSessionEvent::AudioDataReceived { data, .. }) => { + media.push((false, data)); + } + _ => {} + } + } + if media.len() >= want { + return media; + } + let n = stream.read(&mut buffer).await.unwrap(); + if n == 0 { + return media; + } + work.extend(session.handle_input(&buffer[..n]).unwrap()); + } + } + + /// End-to-end play: publish a real broadcast into an origin (via the FLV + /// importer, so it carries a catalog + frames), then drive an RTMP play client + /// and assert it receives the muxed AVC sequence header and keyframe back. + #[tokio::test] + async fn play_streams_broadcast_to_client() { + // An AVC sequence-header tag body: keyframe + AVC CodecID, AVCPacketType 0, + // composition time 0, then a minimal avcC (one SPS, one PPS). + let avcc = { + let sps = [0x67u8, 0x42, 0xc0, 0x1f]; + let mut out = vec![0x01, 0x42, 0xc0, 0x1f, 0xff, 0xe1, 0x00, sps.len() as u8]; + out.extend_from_slice(&sps); + out.extend_from_slice(&[0x01, 0x00, 0x04, 0x68, 0xce, 0x3c, 0x80]); + out + }; + let mut vseq = vec![0x17, 0x00, 0x00, 0x00, 0x00]; + vseq.extend_from_slice(&avcc); + // A keyframe NALU tag body: AVCPacketType 1, then a length-prefixed IDR. + let mut vframe = vec![0x17, 0x01, 0x00, 0x00, 0x00]; + vframe.extend_from_slice(&[0, 0, 0, 5, 0x65, 0x88, 0x84, 0x21, 0x00]); + + // Publish the broadcast at `live/cam0` by feeding synthetic FLV to the importer. + let origin = moq_net::Origin::random().produce(); + let mut broadcast = Broadcast::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap(); + let mut importer = FlvImport::new(broadcast.clone(), catalog); + assert!(origin.publish_broadcast("live/cam0", broadcast.consume())); + importer.decode(&mut flv::file_header()).unwrap(); + importer.decode(&mut flv::tag(flv::TAG_VIDEO, 0, &vseq)).unwrap(); + importer.decode(&mut flv::tag(flv::TAG_VIDEO, 0, &vframe)).unwrap(); + importer.finish().unwrap(); + + let mut server = Server::bind("127.0.0.1:0".parse().unwrap()).await.unwrap(); + let addr = server.local_addr().unwrap(); + let consumer = origin.consume(); + + // Serve the first (play) request against the populated origin. + let server_task = tokio::spawn(async move { + let request = server.accept().await.expect("a request"); + let Request::Play(play) = request else { + panic!("expected a play request"); + }; + play.accept(&consumer, "live/cam0").await.unwrap(); + }); + + let stream = TcpStream::connect(addr).await.unwrap(); + let media = tokio::time::timeout(Duration::from_secs(5), play_client_collect(stream, 2)) + .await + .expect("play client timed out"); + + assert!( + media.len() >= 2, + "expected the seq header and a keyframe, got {}", + media.len() + ); + // First video message is the AVC sequence header (AVCPacketType 0). + assert!(media[0].0, "first message should be video"); + assert_eq!(media[0].1[0], 0x17); + assert_eq!(media[0].1[1], 0x00); + // Second is the keyframe NALU (AVCPacketType 1). + assert!(media[1].0, "second message should be video"); + assert_eq!(media[1].1[1], 0x01); + + server_task.abort(); + } + + #[tokio::test] + async fn accept_yields_publish_request() { + let mut server = Server::bind("127.0.0.1:0".parse().unwrap()).await.unwrap(); + let addr = server.local_addr().unwrap(); + + let client = tokio::spawn(async move { + let stream = TcpStream::connect(addr).await.unwrap(); + run_client(stream, ClientMode::Publish).await; + }); + + let request = tokio::time::timeout(Duration::from_secs(5), server.accept()) + .await + .expect("server.accept timed out") + .expect("server yielded a request"); + + assert_eq!(request.app(), "live"); + assert_eq!(request.stream_key(), "cam0"); + + let Request::Publish(publish) = request else { + panic!("expected a publish request"); + }; + publish.reject("test rejection").await.unwrap(); + client.abort(); + } + + #[tokio::test] + async fn accept_yields_play_request() { + let mut server = Server::bind("127.0.0.1:0".parse().unwrap()).await.unwrap(); + let addr = server.local_addr().unwrap(); + + let client = tokio::spawn(async move { + let stream = TcpStream::connect(addr).await.unwrap(); + run_client(stream, ClientMode::Play).await; + }); + + let request = tokio::time::timeout(Duration::from_secs(5), server.accept()) + .await + .expect("server.accept timed out") + .expect("server yielded a request"); + + assert_eq!(request.app(), "live"); + assert_eq!(request.stream_key(), "cam0"); + + let Request::Play(play) = request else { + panic!("expected a play request"); + }; + play.reject("test rejection").await.unwrap(); + client.abort(); + } + + /// The same publish flow, but over TLS: prove [`Server::with_tls`] terminates + /// RTMPS and yields an identical [`Request`]. Gated on `quinn` because it + /// borrows moq-native's cert generation (`server_config`), which needs a + /// moq-native backend feature. + #[cfg(feature = "quinn")] + #[tokio::test] + async fn rtmps_accept_yields_publish_request() { + use std::sync::Arc; + + use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; + use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; + use rustls::{DigitallySignedStruct, SignatureScheme}; + + // Accept any server cert: the test uses a throwaway self-signed cert. + #[derive(Debug)] + struct NoVerify(Arc); + + impl ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> std::result::Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.0.signature_verification_algorithms) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } + } + + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + + // Server: a self-signed cert for `localhost`, fronting the RTMP listener. + let mut tls = moq_native::tls::Server::default(); + tls.generate = vec!["localhost".to_string()]; + let server_config = tls.server_config(vec![]).expect("build RTMPS server config"); + + let mut server = Server::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap() + .with_tls(server_config); + let addr = server.local_addr().unwrap(); + + // Client: TLS-connect (no verify), then run the ordinary RTMP client. + let client = tokio::spawn(async move { + let client_config = rustls::ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify(provider))) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); + let tcp = TcpStream::connect(addr).await.unwrap(); + let server_name = ServerName::try_from("localhost").unwrap(); + let stream = connector.connect(server_name, tcp).await.unwrap(); + run_client(stream, ClientMode::Publish).await; + }); + + let request = tokio::time::timeout(Duration::from_secs(5), server.accept()) + .await + .expect("server.accept timed out") + .expect("server yielded a request"); + + assert_eq!(request.app(), "live"); + assert_eq!(request.stream_key(), "cam0"); + + let Request::Publish(publish) = request else { + panic!("expected a publish request"); + }; + publish.reject("test rejection").await.unwrap(); + client.abort(); + } +} From 79e02bb8882eec1ea05a850ebc1fe8aa1c4d5d55 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Jun 2026 07:31:50 -0700 Subject: [PATCH 11/34] feat(moq-srt): bidirectional SRT/MPEG-TS gateway (+ timestamped ts::Export) (#1915) Co-authored-by: Claude Opus 4.8 --- Cargo.lock | 201 +++++++- Cargo.toml | 2 + rs/moq-cli/src/subscribe.rs | 4 +- rs/moq-mux/src/container/ts/export.rs | 127 +++-- rs/moq-mux/src/container/ts/export_test.rs | 150 +++++- rs/moq-mux/src/container/ts/import_test.rs | 2 +- rs/moq-srt/Cargo.toml | 62 +++ rs/moq-srt/README.md | 115 +++++ rs/moq-srt/bin/moq-srt.rs | 157 +++++++ rs/moq-srt/bin/serve.rs | 38 ++ rs/moq-srt/bin/web.rs | 64 +++ rs/moq-srt/src/error.rs | 46 ++ rs/moq-srt/src/lib.rs | 40 ++ rs/moq-srt/src/listen.rs | 202 ++++++++ rs/moq-srt/src/server.rs | 516 +++++++++++++++++++++ rs/moq-srt/src/ts.rs | 94 ++++ 16 files changed, 1755 insertions(+), 65 deletions(-) create mode 100644 rs/moq-srt/Cargo.toml create mode 100644 rs/moq-srt/README.md create mode 100644 rs/moq-srt/bin/moq-srt.rs create mode 100644 rs/moq-srt/bin/serve.rs create mode 100644 rs/moq-srt/bin/web.rs create mode 100644 rs/moq-srt/src/error.rs create mode 100644 rs/moq-srt/src/lib.rs create mode 100644 rs/moq-srt/src/listen.rs create mode 100644 rs/moq-srt/src/server.rs create mode 100644 rs/moq-srt/src/ts.rs diff --git a/Cargo.lock b/Cargo.lock index 683a7f86c..586a461f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -1111,6 +1123,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.10.0" @@ -1633,6 +1651,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1648,7 +1679,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -2620,7 +2651,7 @@ version = "0.19.2" dependencies = [ "anyhow", "bytes", - "derive_more", + "derive_more 2.1.1", "hex", "lazy_static", "moq-mux", @@ -3003,7 +3034,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -3235,7 +3266,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2", + "socket2 0.6.4", "widestring", "windows-registry", "windows-result", @@ -3272,7 +3303,7 @@ dependencies = [ "cfg_aliases", "ctutils", "data-encoding", - "derive_more", + "derive_more 2.1.1", "ed25519-dalek", "futures-util", "getrandom 0.4.3", @@ -3320,7 +3351,7 @@ dependencies = [ "curve25519-dalek", "data-encoding", "data-encoding-macro", - "derive_more", + "derive_more 2.1.1", "ed25519-dalek", "getrandom 0.4.3", "n0-error", @@ -3338,7 +3369,7 @@ checksum = "754f7e0c1f67938e1d671007264ffef158f14a9f795a7cc219ea68ea09a9d4c9" dependencies = [ "arc-swap", "cfg_aliases", - "derive_more", + "derive_more 2.1.1", "hickory-resolver", "iroh-base", "n0-error", @@ -3391,7 +3422,7 @@ dependencies = [ "bytes", "cfg_aliases", "data-encoding", - "derive_more", + "derive_more 2.1.1", "getrandom 0.4.3", "hickory-resolver", "http", @@ -3603,6 +3634,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keyed_priority_queue" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" +dependencies = [ + "indexmap 2.14.0", +] + [[package]] name = "kio" version = "0.4.0" @@ -4091,7 +4131,7 @@ dependencies = [ "rustls-webpki", "serde", "serde_with", - "socket2", + "socket2 0.6.4", "tempfile", "thiserror 2.0.18", "tikv-jemalloc-ctl", @@ -4185,7 +4225,7 @@ dependencies = [ "rml_rtmp", "rustls", "sd-notify", - "socket2", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tokio-rustls", @@ -4194,6 +4234,30 @@ dependencies = [ "url", ] +[[package]] +name = "moq-srt" +version = "0.0.1" +dependencies = [ + "anyhow", + "axum", + "axum-server", + "bytes", + "clap", + "futures", + "humantime", + "moq-mux", + "moq-native", + "moq-net", + "rustls", + "sd-notify", + "srt-tokio", + "thiserror 2.0.18", + "tokio", + "tower-http", + "tracing", + "url", +] + [[package]] name = "moq-token" version = "0.6.0" @@ -4244,7 +4308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c6b338be0a8d66954dfb183f8469913c9c435d48885e801debde0953de74563" dependencies = [ "bytes", - "derive_more", + "derive_more 2.1.1", "num", "pastey", "serde", @@ -4293,7 +4357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" dependencies = [ "cfg_aliases", - "derive_more", + "derive_more 2.1.1", "futures-buffered", "futures-lite", "futures-util", @@ -4313,7 +4377,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" dependencies = [ - "derive_more", + "derive_more 2.1.1", "n0-error", "n0-future", ] @@ -4472,7 +4536,7 @@ dependencies = [ "atomic-waker", "bytes", "cfg_aliases", - "derive_more", + "derive_more 2.1.1", "ipnet", "js-sys", "libc", @@ -4489,7 +4553,7 @@ dependencies = [ "objc2-system-configuration", "pin-project-lite", "serde", - "socket2", + "socket2 0.6.4", "time", "tokio", "tokio-util", @@ -4537,13 +4601,13 @@ checksum = "11f0c73794bfde94db01379c46990b9a773993fca2b61a66184ce148b7c7a187" dependencies = [ "bytes", "cfg_aliases", - "derive_more", + "derive_more 2.1.1", "noq-proto", "noq-udp", "pin-project-lite", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -4560,7 +4624,7 @@ dependencies = [ "aes-gcm", "aws-lc-rs", "bytes", - "derive_more", + "derive_more 2.1.1", "enum-assoc", "fastbloom 0.17.0", "getrandom 0.4.3", @@ -4588,7 +4652,7 @@ checksum = "cd5a37756f168cf350d68a97c4f0158bdf3c76f10175123941569b09ab51f011" dependencies = [ "cfg_aliases", "libc", - "socket2", + "socket2 0.6.4", "tracing", "windows-sys 0.61.2", ] @@ -5254,6 +5318,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "pem" version = "3.0.6" @@ -5459,7 +5532,7 @@ checksum = "ccc716c56a0a50f7e4e25f41446419599d47c6197cc5c9858174220e97c272e6" dependencies = [ "base64", "bytes", - "derive_more", + "derive_more 2.1.1", "hyper-util", "igd-next", "iroh-metrics", @@ -5471,7 +5544,7 @@ dependencies = [ "rand 0.10.1", "serde", "smallvec", - "socket2", + "socket2 0.6.4", "time", "tokio", "tokio-util", @@ -5774,7 +5847,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -5814,7 +5887,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -6742,6 +6815,17 @@ dependencies = [ "ref-cast", ] +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.10.6" @@ -6980,6 +7064,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.4" @@ -7051,6 +7145,50 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "srt-protocol" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22790a85cd5d34355e9fc246ded6a1f037add6fd0e0efe4d4914c2d51c20f246" +dependencies = [ + "aes", + "array-init", + "arraydeque", + "bitflags", + "bytes", + "cipher", + "ctr", + "derive_more 0.99.20", + "hex", + "hmac 0.12.1", + "keyed_priority_queue", + "log", + "pbkdf2", + "rand 0.8.6", + "regex", + "sha-1", + "streaming-stats", + "take-until", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "srt-tokio" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a55cb90afac5672b00954e3291846dd262cfef3b52d1b507f580180433373d3" +dependencies = [ + "bytes", + "futures", + "log", + "rand 0.8.6", + "socket2 0.5.10", + "srt-protocol", + "tokio", + "tokio-stream", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7063,6 +7201,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-stats" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d670ce4e348a2081843569e0f79b21c99c91bb9028b3b3ecb0f050306de547" +dependencies = [ + "num-traits", +] + [[package]] name = "strsim" version = "0.11.1" @@ -7178,6 +7325,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "take-until" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" + [[package]] name = "take_mut" version = "0.2.2" @@ -7402,7 +7555,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index f32450372..999979cbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "rs/moq-net", "rs/moq-relay", "rs/moq-rtmp", + "rs/moq-srt", "rs/moq-token", "rs/moq-token-cli", "rs/moq-video", @@ -41,6 +42,7 @@ default-members = [ "rs/moq-native", "rs/moq-relay", "rs/moq-rtmp", + "rs/moq-srt", "rs/moq-token", "rs/moq-token-cli", "rs/moq-video", diff --git a/rs/moq-cli/src/subscribe.rs b/rs/moq-cli/src/subscribe.rs index 6231ba23d..9152b1497 100644 --- a/rs/moq-cli/src/subscribe.rs +++ b/rs/moq-cli/src/subscribe.rs @@ -142,8 +142,8 @@ impl Subscribe { let mut ts = moq_mux::container::ts::Export::with_ts(self.broadcast, self.catalog)?.with_latency(self.args.max_latency); - while let Some(chunk) = ts.next().await? { - stdout.write_all(&chunk).await?; + while let Some(frame) = ts.next().await? { + stdout.write_all(&frame.payload).await?; stdout.flush().await?; } diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index 47c26e674..623ad03bc 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -45,9 +45,12 @@ const PSI_INTERVAL: Duration = Duration::from_millis(500); /// Subscribe to a broadcast and produce an MPEG-TS byte stream. /// -/// Use [`next`](Self::next) to pull byte chunks: the first chunk is PAT+PMT, then -/// each subsequent chunk is the TS packets for one media frame (preceded by a -/// fresh PAT+PMT at video keyframes). Returns `None` when the broadcast ends. +/// Use [`next`](Self::next) to pull one [`Frame`] per media frame: its `payload` +/// is the TS packets, stamped with the source `timestamp` and `keyframe` flag so +/// a transport can pace delivery on the media clock. The leading PAT/PMT rides on +/// the first frame (so it inherits a real timestamp), and is re-emitted at video +/// keyframes and periodically for mid-stream tune-in. Returns `None` when the +/// broadcast ends. pub struct Export { broadcast: moq_net::BroadcastConsumer, catalog: Option>, @@ -63,6 +66,18 @@ pub struct Export { psi: Option, /// Media timestamp of the last PAT/PMT emission. last_psi: Option, + /// Tune-in point: the first video keyframe's timestamp, captured when the program + /// tables are built. Non-video frames before it are dropped so the keyframe leads + /// the stream. + /// + /// MPEG-TS carries the H.264/H.265 parameter sets in-band on the keyframe (unlike + /// RTMP/CMAF, which carry the codec config out-of-band in the header). On a + /// mid-stream join the audio source can start over a second before the oldest + /// cached video keyframe; emitting that lead audio first would bury the parameter + /// sets behind an audio-only preamble, and a live decoder probing the stream gives + /// up before it ever configures video. `None` until the tables are built, and for + /// programs with no video track (nothing to align to). + video_start: Option, } struct Track { @@ -172,6 +187,7 @@ impl Export { program_descriptors: Vec::new(), psi: None, last_psi: None, + video_start: None, }) } @@ -181,12 +197,22 @@ impl Export { self } - /// Get the next byte chunk. - pub async fn next(&mut self) -> anyhow::Result> { + /// Get the next muxed frame. + /// + /// Each [`Frame`] carries the TS packets for one media frame in `payload`, + /// stamped with that frame's media `timestamp` and `keyframe` flag so a + /// transport can pace delivery on the media clock. The leading PAT/PMT rides + /// on the first frame (inheriting its timestamp), and is re-emitted at video + /// keyframes and periodically for mid-stream tune-in. Returns `None` when the + /// broadcast ends. + pub async fn next(&mut self) -> anyhow::Result> { kio::wait(|waiter| self.poll_next(waiter)).await } - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + /// Poll for the next muxed frame, driving the underlying sources via `waiter`. + /// The `Poll::Ready` counterpart of [`next`](Self::next), for synchronous or + /// custom executors. + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { // 1. Drain catalog updates, discovering the track layout. while let Some(catalog) = self.catalog.as_mut() { match catalog.poll_next(waiter)? { @@ -206,16 +232,25 @@ impl Export { // can't use them, and parking them would stop us polling for the keyframe // that carries the parameter sets. let waiting_for_header = self.psi.is_none(); + let video_start = self.video_start; for track in self.tracks.values_mut() { if track.pending.is_some() || track.finished { continue; } + let is_video = matches!(track.kind, Kind::Video(_)); loop { match track.source.poll_read(waiter) { Poll::Ready(Ok(Some(frame))) => { if waiting_for_header && !track.source.header_ready() { continue; } + // Tune-in alignment: drop non-video frames before the first video + // keyframe (see `video_start`) so the in-band SPS/PPS leads the stream. + if let Some(start) = video_start + && !is_video && frame.timestamp < start + { + continue; + } track.pending = Some(frame); break; } @@ -229,8 +264,10 @@ impl Export { } } - // 3. Emit the program tables once the layout is resolved and every - // track's codec config is ready. + // 3. Build the program tables once the layout is resolved and every + // track's codec config is ready. The tables aren't emitted here: PSI has + // no media time of its own, so `write_frame` prepends them to the first + // frame instead, letting the leading PAT/PMT inherit a real timestamp. if self.psi.is_none() { if self.tracks.is_empty() { // No tracks yet. If the catalog is also done, the broadcast is empty. @@ -239,24 +276,38 @@ impl Export { } return Poll::Pending; } - if !self.header_ready() { - // Still waiting on codec configs. If every track finished without - // producing one, the broadcast can't be muxed. + if !self.header_ready() || !self.video_ready() { + // Hold all output (tables and audio alike) until codec configs resolve + // and, when the program has a video rendition, its first keyframe is + // buffered: the stream must begin on that keyframe so the in-band + // parameter sets lead it. An audio-only program has nothing to wait for. + // If every track finished without producing a config, it can't be muxed. if self.catalog.is_none() && self.tracks.values().all(|t| t.finished) { return Poll::Ready(Ok(None)); } return Poll::Pending; } self.build_psi()?; - let header = self.write_psi()?; - return Poll::Ready(Ok(Some(header))); + // Anchor tune-in to the first video keyframe and drop any non-video frame + // already buffered ahead of it (see `video_start`). + self.video_start = self.first_video_pts(); + if let Some(start) = self.video_start { + for track in self.tracks.values_mut() { + if !matches!(track.kind, Kind::Video(_)) + && track.pending.as_ref().is_some_and(|f| f.timestamp < start) + { + track.pending = None; + } + } + } } - // 4. Emit the smallest-timestamp pending frame as a PES packet. + // 4. Emit the smallest-timestamp pending frame as a PES packet (the first + // one carries the buffered PAT/PMT). if let Some(name) = self.pick_next_track() { let frame = self.tracks.get_mut(&name).unwrap().pending.take().unwrap(); - let chunk = self.write_frame(&name, frame)?; - return Poll::Ready(Ok(Some(chunk))); + let out = self.write_frame(&name, frame)?; + return Poll::Ready(Ok(Some(out))); } // 5. End of stream once every track has drained and the catalog is closed. @@ -433,6 +484,28 @@ impl Export { self.tracks.values().all(|t| t.source.header_ready()) } + /// Every video track has buffered its first frame (the keyframe) or finished. + /// The tables wait for this so the tune-in point ([`Self::video_start`]) can be + /// read from the keyframe before any audio is emitted ahead of it. A program + /// with no video track is trivially ready. + fn video_ready(&self) -> bool { + self.tracks + .values() + .filter(|t| matches!(t.kind, Kind::Video(_))) + .all(|t| t.pending.is_some() || t.finished) + } + + /// The smallest timestamp among the video tracks' buffered frames: the first + /// video keyframe, since pre-keyframe video frames are dropped before the tables + /// are built. `None` when no video track has a buffered frame (audio-only program). + fn first_video_pts(&self) -> Option { + self.tracks + .values() + .filter(|t| matches!(t.kind, Kind::Video(_))) + .filter_map(|t| t.pending.as_ref().map(|f| f.timestamp)) + .min() + } + /// Build the PAT/PMT once every track's PID and codec is known. fn build_psi(&mut self) -> anyhow::Result<()> { // Order tracks by PID for a stable layout; first video track carries the PCR. @@ -552,18 +625,6 @@ impl Export { Ok(()) } - /// Serialize a fresh PAT + PMT into a chunk. - fn write_psi(&mut self) -> anyhow::Result { - let psi = self.psi.as_ref().context("PSI not built")?; - let pat = TsPayload::Pat(psi.pat.clone()); - let pmt = TsPayload::Pmt(psi.pmt.clone()); - - let mut out = Vec::with_capacity(2 * TsPacket::SIZE); - self.write_packet(&mut out, Pid::PAT, None, pat)?; - self.write_packet(&mut out, PMT_PID, None, pmt)?; - Ok(Bytes::from(out)) - } - fn pick_next_track(&self) -> Option { self.tracks .iter() @@ -574,12 +635,14 @@ impl Export { /// Packetize one media frame into a chunk, re-emitting PAT/PMT before video /// keyframes (and periodically) so receivers can tune in mid-stream. - fn write_frame(&mut self, name: &str, frame: Frame) -> anyhow::Result { + fn write_frame(&mut self, name: &str, frame: Frame) -> anyhow::Result { let track = self.tracks.get(name).context("missing track")?; let pid = track.pid; let kind = track.kind.clone(); let is_pcr = self.psi.as_ref().is_some_and(|p| p.pcr_pid == pid); let is_video = matches!(kind, Kind::Video(_)); + let timestamp = frame.timestamp; + let keyframe = frame.keyframe; // Build the elementary-stream payload for this frame. Video needs the // resolved avcC/hvcC to rewrite length-prefixed NALs as Annex-B. Section-framed @@ -660,7 +723,11 @@ impl Export { self.write_pes(&mut out, &unit, &es_payload)?; } } - Ok(Bytes::from(out)) + Ok(Frame { + timestamp, + payload: Bytes::from(out), + keyframe, + }) } /// Packetize a PES payload into 188-byte TS packets. diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 5ecb787ec..478eabd23 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -67,10 +67,10 @@ async fn drain_with(mut exporter: Export) -> BytesMut { let mut out = BytesMut::new(); // `while let Ok` stops on the first timeout (`Pending`: no more output). while let Ok(res) = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()).await { - let Some(chunk) = res.expect("exporter error") else { + let Some(frame) = res.expect("exporter error") else { break; }; - out.extend_from_slice(&chunk); + out.extend_from_slice(&frame.payload); } out } @@ -158,6 +158,130 @@ async fn export_aac_roundtrip() { } } +/// Collect PES presentation timestamps per elementary stream (video H.264, audio AAC), +/// keyed off the PMT's PID assignments. +fn collect_pes_pts(ts: &[u8]) -> (Vec, Vec) { + let mut reader = TsPacketReader::new(Cursor::new(ts)); + let (mut video_pid, mut audio_pid) = (None, None); + let (mut video, mut audio) = (Vec::new(), Vec::new()); + while let Some(packet) = reader.read_ts_packet().unwrap() { + match packet.payload { + Some(TsPayload::Pmt(pmt)) => { + for es in &pmt.es_info { + match es.stream_type { + StreamType::H264 => video_pid = Some(es.elementary_pid), + StreamType::AdtsAac => audio_pid = Some(es.elementary_pid), + _ => {} + } + } + } + Some(TsPayload::PesStart(pes)) => { + if let Some(pts) = pes.header.pts { + let pid = Some(packet.header.pid); + if pid == video_pid { + video.push(pts.as_u64()); + } else if pid == audio_pid { + audio.push(pts.as_u64()); + } + } + } + _ => {} + } + } + (video, audio) +} + +/// Build a broadcast whose audio begins before the first video keyframe (the shape a +/// mid-stream tune-in produces: the audio source is cached further back than the oldest +/// retained video keyframe), then export it to TS. +async fn export_lead_audio() -> BytesMut { + let mut broadcast = moq_net::Broadcast::new().produce(); + let consumer = broadcast.consume(); + let mut catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + + // In-band avc3 video (SPS/PPS inline on keyframes; no out-of-band description). + let vtrack = broadcast.unique_track(".avc3").unwrap(); + let vname = vtrack.name.clone(); + { + let mut cfg = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0xc0, + level: 0x1f, + inline: true, + }); + cfg.container = Container::Legacy; + catalog.lock().video.renditions.insert(vname, cfg); + } + let mut video = Producer::new(vtrack, HangContainer::Legacy); + + let atrack = broadcast.unique_track(".aac").unwrap(); + let aname = atrack.name.clone(); + { + let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2); + cfg.container = Container::Legacy; + catalog.lock().audio.renditions.insert(aname, cfg); + } + let mut audio = Producer::new(atrack, HangContainer::Legacy); + + let audio_frame = |ms: u64| Frame { + timestamp: Timestamp::from_millis(ms).unwrap(), + payload: Bytes::from(vec![0xAAu8; 16]), + keyframe: true, + }; + // Lead audio (0..80 ms) precedes the first video keyframe at 100 ms; both continue after. + for ms in [0, 20, 40, 60, 80] { + audio.write(audio_frame(ms)).unwrap(); + audio.finish_group().unwrap(); + } + let mut idr = vec![0x65u8]; + idr.extend(std::iter::repeat_n(0xAB, 200)); + video + .write(Frame { + timestamp: Timestamp::from_millis(100).unwrap(), + payload: annexb(&[SPS, PPS, &idr]), + keyframe: true, + }) + .unwrap(); + video.finish_group().unwrap(); + for ms in [100, 120, 140] { + audio.write(audio_frame(ms)).unwrap(); + audio.finish_group().unwrap(); + } + video.finish().unwrap(); + audio.finish().unwrap(); + + let exporter = Export::new(consumer).unwrap(); + // The producers stay alive through the drain so the retained tracks are readable. + drain_with(exporter).await +} + +/// The exported stream must begin at the first video keyframe. On a mid-stream tune-in the +/// audio source can lead the first cached video keyframe by over a second; emitting that +/// audio first buries the in-band SPS/PPS behind an audio-only preamble, and a live decoder +/// probing the stream gives up before it ever configures video (RTMP/CMAF carry the codec +/// config out-of-band, so they don't hit this). The muxer drops the lead audio so the +/// keyframe leads. Audio from the keyframe onward is still carried. +#[tokio::test(start_paused = true)] +async fn export_starts_at_video_keyframe() { + // 100 ms (the keyframe PTS) in 90 kHz ticks. + const KEYFRAME_PTS: u64 = 100 * 90; + + let ts = export_lead_audio().await; + assert_packet_aligned(&ts); + let (video, audio) = collect_pes_pts(&ts); + + assert_eq!( + video.first(), + Some(&KEYFRAME_PTS), + "the stream must begin at the video keyframe" + ); + assert!( + audio.iter().all(|&p| p >= KEYFRAME_PTS), + "lead audio before the first keyframe must be dropped, got {audio:?}" + ); + assert!(!audio.is_empty(), "audio from the keyframe onward is still carried"); +} + /// Re-parse a TS byte stream: assert the single video stream type, that the /// keyframe carries random-access + PCR in an unbounded PES, and return the /// reassembled Annex-B elementary stream. @@ -431,9 +555,11 @@ async fn export_scte35_roundtrip() { catalog.lock().mpegts.tracks.insert(scte_name.clone(), track); } let mut scte_producer = Producer::new(scte, HangContainer::Legacy); + // bbb's first video keyframe is at 1.4 s; stamp the cue just after it so it survives + // the muxer's keyframe-aligned tune-in (which drops non-video frames before the keyframe). scte_producer .write(Frame { - timestamp: Timestamp::from_millis(40).unwrap(), + timestamp: Timestamp::from_millis(1410).unwrap(), payload: Bytes::from_static(CUE), keyframe: true, }) @@ -533,9 +659,11 @@ async fn export_pes_verbatim_roundtrip() { catalog.lock().mpegts.tracks.insert(data_name.clone(), track); } let mut data_producer = Producer::new(data_track, HangContainer::Legacy); + // bbb's first video keyframe is at 1.4 s; stamp the PES just after it so it survives + // the muxer's keyframe-aligned tune-in (which drops non-video frames before the keyframe). data_producer .write(Frame { - timestamp: Timestamp::from_millis(40).unwrap(), + timestamp: Timestamp::from_millis(1410).unwrap(), payload: Bytes::from_static(PAYLOAD), keyframe: true, }) @@ -707,10 +835,16 @@ async fn mp2_kyrion_roundtrip_byte_exact() { roundtripped.push(read_frames(&consumer2, name).await); } - // Track discovery order is not stable across imports; compare as sorted sets. - ingested.sort(); - roundtripped.sort(); - assert_eq!(roundtripped, ingested, "MP2 frames must survive byte-for-byte"); + // Keyframe alignment drops the MP2 ahead of the first video keyframe (the dirty-start + // lead), so each program's surviving frames are a byte-exact suffix of what was + // ingested. Track discovery order is not stable across imports, so match by content. + for rt in &roundtripped { + assert!(!rt.is_empty(), "a program lost all of its MP2 frames"); + assert!( + ingested.iter().any(|ing| ing.ends_with(rt)), + "round-tripped MP2 must be a byte-exact suffix of an ingested program" + ); + } } /// The ffmpeg AC-3 fixture must survive TS -> MoQ -> TS byte-for-byte in an diff --git a/rs/moq-mux/src/container/ts/import_test.rs b/rs/moq-mux/src/container/ts/import_test.rs index 8ba735652..4b27bce67 100644 --- a/rs/moq-mux/src/container/ts/import_test.rs +++ b/rs/moq-mux/src/container/ts/import_test.rs @@ -230,7 +230,7 @@ async fn import_export_import_roundtrip() { let mut out = BytesMut::new(); while let Ok(res) = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()).await { match res.expect("exporter error") { - Some(chunk) => out.extend_from_slice(&chunk), + Some(frame) => out.extend_from_slice(&frame.payload), None => break, } } diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml new file mode 100644 index 000000000..d442d6a16 --- /dev/null +++ b/rs/moq-srt/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "moq-srt" +description = "Bidirectional SRT gateway for Media over QUIC" +authors = ["Luke Curley "] +repository = "https://github.com/moq-dev/moq" +license = "MIT OR Apache-2.0" + +version = "0.0.1" +edition = "2024" +rust-version.workspace = true + +keywords = ["srt", "moq", "media", "live", "mpeg-ts"] +categories = ["multimedia", "network-programming", "web-programming"] + +[lib] +doctest = false + +[[bin]] +name = "moq-srt" +path = "bin/moq-srt.rs" +doc = false +# The binary (relay client/server, CLI) needs the `server` feature; the library +# can be depended on with `default-features = false` for ingest only (e.g. a +# relay that embeds the SRT listener and publishes into its own origin). +required-features = ["server"] + +[features] +default = ["server", "noq", "websocket"] +# Relay client/server transports + the moq-srt binary. Pulls in moq-native / +# axum / clap / rustls. +server = ["dep:axum", "dep:axum-server", "dep:clap", "dep:humantime", "dep:moq-native", "dep:rustls", "dep:sd-notify", "dep:tower-http", "dep:url"] +noq = ["server", "moq-native/noq"] +quinn = ["server", "moq-native/quinn"] +quiche = ["server", "moq-native/quiche"] +websocket = ["server", "moq-native/websocket"] +iroh = ["server", "moq-native/iroh"] + +# The ingest library needs only anyhow/bytes/futures/moq-mux/moq-net/srt-tokio/ +# thiserror/tokio/tracing; the rest (axum/clap/moq-native/rustls/tower-http/url/ +# sd-notify) are `optional` and pulled in by the `server` feature for the binary. +[dependencies] +anyhow = { version = "1", features = ["backtrace"] } +axum = { version = "0.8", features = ["tokio"], optional = true } +axum-server = { version = "0.8", features = ["tls-rustls"], optional = true } +bytes = "1" +clap = { version = "4", features = ["derive"], optional = true } +futures = "0.3" +humantime = { version = "2.3", optional = true } +moq-mux = { workspace = true } +moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"], optional = true } +moq-net = { workspace = true } +rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"], optional = true } +# Pure-Rust SRT (no libsrt / ffmpeg). SRT carries MPEG-TS, demuxed by moq-mux. +srt-tokio = "0.4" +thiserror = "2" +tokio = { workspace = true, features = ["full"] } +tower-http = { version = "0.6", features = ["cors", "fs"], optional = true } +tracing = "0.1" +url = { version = "2", optional = true } + +[target.'cfg(unix)'.dependencies] +sd-notify = { version = "0.5", optional = true } diff --git a/rs/moq-srt/README.md b/rs/moq-srt/README.md new file mode 100644 index 000000000..a4c737f87 --- /dev/null +++ b/rs/moq-srt/README.md @@ -0,0 +1,115 @@ +# moq-srt + +SRT gateway for Media over QUIC, both directions. + +SRT carries MPEG-TS. This crate runs an SRT listener and routes each connection +by its stream id `m=` mode: + +- `m=publish` (the default): ingest. Demux the connection's transport stream + with [`moq-mux`](../moq-mux) and publish it into a MoQ origin as an ordinary + broadcast. The contribution-ingest analogue of `moq-hls`'s import and + `moq-rtc`'s WHIP. +- `m=request`: egress. Re-mux a broadcast from the origin back to MPEG-TS and + stream it to the caller, so `vlc srt://...` and `ffmpeg -i srt://...` can play + any broadcast the origin carries (H.264/H.265 video, AAC/AC-3/MP2 audio). + +Pure Rust: SRT is provided by `srt-tokio`, with no libsrt or ffmpeg dependency. + +## Library + +Two entry points. `Config` + `run` is the unauthenticated convenience: a relay +embeds ingest by calling `run` against its own origin, so the ingested media is +published locally with no extra hop. For auth, drive `Server` / `Request` +directly (see [Auth](#auth) below). Depend on it with `default-features = false` +to skip the binary's relay client/server and CLI dependencies: + +```toml +moq-srt = { version = "0.0.1", default-features = false } +``` + +```rust +let mut srt = moq_srt::Config::default(); +srt.listen = Some("0.0.0.0:9000".parse()?); +srt.prefix = "live/".to_string(); + +// `origin` is your relay's local origin (e.g. `cluster.origin.clone()`). +tokio::select! { + res = moq_srt::run(origin, srt) => res?, + // ... your relay's accept loop, web server, etc. +} +``` + +## Binary + +The `moq-srt` binary (needs the default `server` feature) has two modes. + +`serve` ingests SRT and serves it directly as a local relay, so MoQ subscribers +(native or browser) connect straight to this binary. It also exposes the +`/certificate.sha256` endpoint browsers need for self-signed `http://` origins, +and can serve a static player directory with `--dir`: + +```bash +moq-srt serve --server-bind [::]:443 --tls-generate localhost \ + --srt-listen 0.0.0.0:9000 --srt-prefix live/ +``` + +`publish` instead forwards every ingested broadcast out to a remote relay over +WebTransport (like `moq-hls import`): + +```bash +moq-srt publish --relay https://relay.example.com \ + --srt-listen 0.0.0.0:9000 --srt-prefix live/ +``` + +Feed either mode with any SRT source: + +```bash +# Publish: lands at broadcast `live/cam0`. +ffmpeg -re -i input.mp4 -c copy -f mpegts \ + 'srt://127.0.0.1:9000?streamid=#!::r=cam0,m=publish' + +# Request: play `live/cam0` back out as MPEG-TS. +ffplay 'srt://127.0.0.1:9000?streamid=#!::r=cam0,m=request' +vlc 'srt://127.0.0.1:9000?streamid=#!::r=cam0,m=request' +``` + +A request waits for the broadcast to be announced, so a player may connect before +the publisher does. + +## Routing + +Each connection's broadcast path and direction come from its SRT stream id: + +- Standard form `#!::r=,m=` -> ``, with `m=request` + selecting egress and anything else (including absent) selecting ingest. +- Otherwise the raw stream id (e.g. OBS-style `app/key`), always ingest. + +`--srt-prefix` is prepended to namespace a listener's streams. First publisher on +a path wins; a second publish of the same path is rejected. Requests don't claim +a path, so any number of players can pull the same broadcast. + +## Auth + +`run` is unauthenticated: anyone who can reach the UDP port can publish or +request any broadcast. Gate it with a host firewall or a private network, or +bring your own auth by driving `Server` / `Request` directly, mirroring +`moq-native`'s `Server` / `Request`: + +```rust +let mut server = moq_srt::Server::bind("0.0.0.0:9000".parse()?, None).await?; +while let Some(request) = server.accept().await { + // Inspect `request.resource()` / `request.stream_id()` (treat the stream id + // as a token if you like), verify it, and pick the broadcast path. + match request { + moq_srt::Request::Publish(publish) => { + tokio::spawn(publish.accept(&origin, "live/cam0")); + } + moq_srt::Request::Subscribe(subscribe) => { + tokio::spawn(subscribe.accept(&consumer, "live/cam0")); + } + } + // ...or `request`'s `reject()` to deny it. +} +``` + +SRT passphrase encryption is a separate, planned next step. diff --git a/rs/moq-srt/bin/moq-srt.rs b/rs/moq-srt/bin/moq-srt.rs new file mode 100644 index 000000000..d638ce399 --- /dev/null +++ b/rs/moq-srt/bin/moq-srt.rs @@ -0,0 +1,157 @@ +//! `moq-srt` binary. +//! +//! Runs an SRT gateway (MPEG-TS in via `m=publish`, out via `m=request`) and +//! connects it to MoQ two ways: +//! +//! - `serve` runs a local QUIC/WebTransport server so subscribers connect +//! straight to this binary (no separate relay needed). Ingested broadcasts are +//! also requestable back out over SRT. +//! - `publish` forwards every ingested broadcast out to a remote relay over +//! WebTransport, like `moq-hls import` / `moq-rtc` WHIP. SRT requests are +//! served from the local origin (broadcasts ingested by this same process). +//! +//! A relay that wants an in-process gateway should instead depend on the +//! `moq-srt` library and call `moq_srt::run` against its own origin. + +mod serve; +mod web; + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use clap::{Args, Parser, Subcommand}; +use url::Url; + +#[derive(Parser, Clone)] +#[command(name = "moq-srt", version)] +struct Cli { + #[command(flatten)] + log: moq_native::Log, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Clone)] +enum Command { + /// Ingest SRT and serve it directly as a local relay. + Serve { + /// The QUIC/WebTransport server configuration. + #[command(flatten)] + config: moq_native::ServerConfig, + + /// Optionally serve static files (e.g. a web player) from this directory. + #[arg(long)] + dir: Option, + + #[command(flatten)] + srt: SrtArgs, + }, + /// Ingest SRT and publish the broadcasts to a remote MoQ relay. + Publish { + /// The MoQ client configuration. + #[command(flatten)] + config: moq_native::ClientConfig, + + /// URL of the MoQ relay to publish into (e.g. `https://relay.example.com`). + /// + /// `https://` makes a WebTransport connection over QUIC. `http://` first + /// fetches `/certificate.sha256` for the TLS fingerprint (insecure). The + /// `?jwt=` query parameter supplies a moq-token-cli JWT; otherwise the + /// public path (if any) is used. + #[arg(long, env = "MOQ_SRT_RELAY")] + relay: Url, + + #[command(flatten)] + srt: SrtArgs, + }, +} + +/// CLI flags for the SRT listener, converted into a [`moq_srt::Config`]. +#[derive(Args, Clone)] +struct SrtArgs { + /// Address to listen on for SRT ingest (e.g. `0.0.0.0:9000`). + #[arg(long = "srt-listen", env = "MOQ_SRT_LISTEN")] + listen: SocketAddr, + + /// Prefix prepended to every ingested broadcast path (e.g. `live/`). + #[arg(long = "srt-prefix", env = "MOQ_SRT_PREFIX", default_value = "")] + prefix: String, + + /// SRT receive latency: the negotiated buffer that trades delay for loss recovery. + #[arg(long = "srt-latency", env = "MOQ_SRT_LATENCY", default_value = "200ms", value_parser = humantime::parse_duration)] + latency: Duration, +} + +impl From for moq_srt::Config { + fn from(args: SrtArgs) -> Self { + let mut config = moq_srt::Config::default(); + config.listen = Some(args.listen); + config.prefix = args.prefix; + config.latency = args.latency; + config + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // moq-native pulls in `ring` somewhere transitively, so install the + // aws-lc-rs provider explicitly (mirrors moq-cli's main). + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("failed to install default crypto provider"); + + let cli = Cli::parse(); + cli.log.init()?; + + match cli.command { + Command::Serve { config, dir, srt } => run_serve(config, dir, srt.into()).await, + Command::Publish { config, relay, srt } => run_publish(config, relay, srt.into()).await, + } +} + +/// Run a local QUIC/WebTransport server and ingest SRT directly into it. +async fn run_serve(config: moq_native::ServerConfig, dir: Option, srt: moq_srt::Config) -> anyhow::Result<()> { + let server = config.init().context("init moq server")?; + // Derive the HTTP sidecar bind from the actual listener, so a port-0 or + // hostname-resolved server still serves /certificate.sha256 on the real socket. + let web_bind = server.local_addr().context("server local addr")?.to_string(); + let web_tls = server.tls_info(); + + // SRT publishes broadcasts into this origin; the server serves them out. + let origin = moq_net::Origin::random().produce(); + + tracing::info!(%web_bind, "moq-srt serving"); + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + tokio::select! { + res = serve::run(server, origin.clone()) => res.context("moq server failed"), + res = web::run(&web_bind, web_tls, dir) => res.context("web server failed"), + res = moq_srt::run(origin, srt) => res.context("srt ingest failed"), + _ = tokio::signal::ctrl_c() => Ok(()), + } +} + +/// Ingest SRT and forward every broadcast to a remote relay at `relay`. +async fn run_publish(config: moq_native::ClientConfig, relay: Url, srt: moq_srt::Config) -> anyhow::Result<()> { + let client = config.init().context("init moq client")?; + + // SRT publishes broadcasts into this origin; the client forwards them on. + let origin = moq_net::Origin::random().produce(); + let reconnect = client.with_publish(origin.consume()).reconnect(relay.clone()); + + tracing::info!(%relay, "moq-srt publishing"); + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + tokio::select! { + res = moq_srt::run(origin, srt) => res.context("srt ingest failed"), + res = reconnect.closed() => res.context("relay connection failed"), + _ = tokio::signal::ctrl_c() => Ok(()), + } +} diff --git a/rs/moq-srt/bin/serve.rs b/rs/moq-srt/bin/serve.rs new file mode 100644 index 000000000..d49e476b8 --- /dev/null +++ b/rs/moq-srt/bin/serve.rs @@ -0,0 +1,38 @@ +//! Local QUIC/WebTransport server for `serve` mode. +//! +//! Accepts MoQ sessions and serves every broadcast the SRT listener has +//! published into `origin`, so subscribers connect straight to this binary +//! instead of a separate relay. + +use moq_net::{OriginConsumer, OriginProducer}; + +/// Accept sessions and publish `origin`'s broadcasts to each subscriber. +pub async fn run(mut server: moq_native::Server, origin: OriginProducer) -> anyhow::Result<()> { + tracing::info!(addr = ?server.local_addr(), "listening"); + + let mut conn_id = 0; + while let Some(request) = server.accept().await { + let id = conn_id; + conn_id += 1; + + let consumer = origin.consume(); + tokio::spawn(async move { + if let Err(err) = serve_session(id, request, consumer).await { + tracing::warn!(%err, "session ended"); + } + }); + } + + anyhow::bail!("server stopped accepting connections") +} + +#[tracing::instrument("session", skip_all, fields(id))] +async fn serve_session(id: u64, request: moq_native::Request, consumer: OriginConsumer) -> anyhow::Result<()> { + // Blindly accept the session (WebTransport or QUIC), serving every ingested + // broadcast to the subscriber. + let session = request.with_publish(consumer).ok().await?; + + tracing::info!(id, "accepted session"); + + session.closed().await.map_err(Into::into) +} diff --git a/rs/moq-srt/bin/web.rs b/rs/moq-srt/bin/web.rs new file mode 100644 index 000000000..9492c9113 --- /dev/null +++ b/rs/moq-srt/bin/web.rs @@ -0,0 +1,64 @@ +//! HTTP sidecar for `serve` mode. +//! +//! Serves `/certificate.sha256` (the TLS fingerprint browsers fetch when +//! connecting to a self-signed `http://` origin) and, optionally, a static +//! directory for local development. Mirrors `moq-cli`'s web server. + +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use anyhow::Context; +use axum::handler::HandlerWithoutStateExt; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Router, http::Method, routing::get}; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; + +/// Serve the cert-fingerprint endpoint (and optional static `public` dir) on `bind`. +pub async fn run( + bind: &str, + tls_info: Arc>, + public: Option, +) -> anyhow::Result<()> { + let listen = tokio::net::lookup_host(bind) + .await + .context("invalid listen address")? + .next() + .context("invalid listen address")?; + + async fn handle_404() -> impl IntoResponse { + (StatusCode::NOT_FOUND, "Not found") + } + + let fingerprint_handler = move || async move { + tls_info + .read() + .expect("tls_info read lock poisoned") + .fingerprints + .first() + .expect("missing certificate") + .clone() + }; + + let mut app = Router::new() + .route("/certificate.sha256", get(fingerprint_handler)) + .layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])); + + if let Some(public) = public.as_ref() { + tracing::info!(public = %public.display(), "serving directory"); + + let public = ServeDir::new(public).not_found_service(handle_404.into_service()); + app = app.fallback_service(public); + } else { + app = app.fallback_service(handle_404.into_service()); + } + + // Dual-stack so the cert endpoint answers over IPv4 too, even on Windows + // where `[::]` is IPv6-only by default. + let listener = moq_native::bind::tcp(listen).context("failed to bind web listener")?; + let server = axum_server::from_tcp(listener)?; + server.serve(app.into_make_service()).await?; + + Ok(()) +} diff --git a/rs/moq-srt/src/error.rs b/rs/moq-srt/src/error.rs new file mode 100644 index 000000000..3b2af046b --- /dev/null +++ b/rs/moq-srt/src/error.rs @@ -0,0 +1,46 @@ +//! Errors for the SRT ingest gateway. + +use std::sync::Arc; + +/// Errors produced while ingesting SRT into MoQ. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// Error from the underlying moq-net transport (e.g. publishing into the origin). + #[error("moq: {0}")] + Moq(#[from] moq_net::Error), + + /// Error from the moq-mux muxer/demuxer (TS demux on ingest, TS mux on egress). + #[error("mux: {0}")] + Mux(Arc), + + /// I/O error from the SRT listener or socket. + #[error("io: {0}")] + Io(Arc), + + /// Catch-all for ingest logic that reports via `anyhow` (the moq-mux + /// demuxer surfaces its errors this way). + #[error("{0}")] + Other(Arc), +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Error::Io(Arc::new(err)) + } +} + +impl From for Error { + fn from(err: moq_mux::Error) -> Self { + Error::Mux(Arc::new(err)) + } +} + +impl From for Error { + fn from(err: anyhow::Error) -> Self { + Error::Other(Arc::new(err)) + } +} + +/// Result alias for the SRT ingest gateway. +pub type Result = std::result::Result; diff --git a/rs/moq-srt/src/lib.rs b/rs/moq-srt/src/lib.rs new file mode 100644 index 000000000..cc080f490 --- /dev/null +++ b/rs/moq-srt/src/lib.rs @@ -0,0 +1,40 @@ +//! SRT gateway for MoQ, both directions. +//! +//! Runs an [SRT](https://www.haivision.com/products/srt-secure-reliable-transport/) +//! listener and routes each connection by its stream-id `m=` mode against a +//! [`moq_net::OriginProducer`]: +//! +//! - `m=publish` (the default): demux the MPEG-TS the connection carries with +//! [`moq_mux`] and publish it into the origin as an ordinary broadcast. The +//! contribution-ingest analogue of `moq-hls`'s import and `moq-rtc`'s WHIP. +//! - `m=request`: re-mux a broadcast from the origin back to MPEG-TS and stream +//! it to the caller, so a plain SRT player (VLC, ffmpeg) can watch it. +//! +//! Two entry points, depending on how much control you need over each request: +//! +//! - [`run`]: the unauthenticated convenience. Build a [`Config`] and hand it +//! plus an origin to [`run`]; it accepts every publisher and subscriber and +//! routes by prefix + resource name. A relay embeds this with +//! `run(cluster.origin.clone(), config)`. +//! - [`Server`] / [`Request`]: bring your own auth. Loop on [`Server::accept`], +//! inspect [`Request::resource`] / [`Request::stream_id`] (treat the stream id +//! as a token if you like), then match on the [`Request`]: accept a [`Publish`] +//! into an origin, or accept a [`Subscribe`] out of one, at a path of your +//! choosing (or reject it). This is how an embedder (e.g. a relay verifying a +//! JWT and scoping the origin per token) plugs its policy in. It mirrors +//! `moq-native`'s `Server` / `Request`. +//! +//! The bundled `moq-srt` binary serves the origin locally or forwards it to a +//! remote relay (those paths need the `server` feature). +//! +//! Pure Rust: SRT is provided by `srt-tokio`, with no libsrt or ffmpeg +//! dependency. + +mod error; +mod listen; +mod server; +mod ts; + +pub use error::{Error, Result}; +pub use listen::{Config, run}; +pub use server::{Publish, Request, Server, Subscribe}; diff --git a/rs/moq-srt/src/listen.rs b/rs/moq-srt/src/listen.rs new file mode 100644 index 000000000..777e026e0 --- /dev/null +++ b/rs/moq-srt/src/listen.rs @@ -0,0 +1,202 @@ +//! SRT listener configuration and the unauthenticated `run` convenience. +//! +//! SRT is a thin reliability/encryption layer over a UDP datagram stream whose +//! payload, by overwhelming convention, is MPEG-TS. [`run`] drives a [`Server`]: +//! it accepts every connection and routes each by its stream-id `m=` mode, in one +//! of two directions: +//! +//! - `m=publish` (the default): ingest. Pump the caller's TS payload into the +//! origin as a broadcast. +//! - `m=request`: egress. Re-mux the requested broadcast back to MPEG-TS and +//! stream it to the caller, so VLC / ffmpeg can play +//! `srt://host:port?streamid=#!::r=,m=request`. +//! +//! Routing: SRT's recommended stream-id form is `#!::r=,m=`. The +//! `r=` resource (or the raw stream id, for OBS-style clients) names the +//! broadcast, and the optional [`prefix`](Config::prefix) is prepended so a +//! single listener can namespace all of its streams (e.g. prefix `live/` + stream +//! id `cam0` -> broadcast `live/cam0`). +//! +//! Auth: [`run`] is unauthenticated. Anyone who can reach the UDP port can +//! publish or request any broadcast, so gate it with the host firewall / a +//! private network. To gate access (e.g. verify the stream id as a JWT) or to +//! scope the origin per client, drive [`Server`] directly: loop on +//! [`Server::accept`], match the [`Request`], and call accept/reject after making +//! your own decision. + +use std::collections::HashSet; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use moq_net::OriginProducer; + +use crate::Result; +use crate::server::{Request, Server}; + +/// SRT gateway configuration. +/// +/// Construct via [`Config::default`] and set the fields you need, so new +/// options stay additive. The listener is disabled (and [`run`] stays pending) +/// unless [`listen`](Config::listen) is set, letting an embedding relay run +/// without SRT until it's configured. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Config { + /// Address to listen on for SRT (e.g. `0.0.0.0:9000`). When `None`, the SRT + /// gateway is disabled. + pub listen: Option, + + /// Prefix prepended to every broadcast path, for both publish and request. + /// Lets one listener namespace all of its streams (e.g. `live/`). + pub prefix: String, + + /// SRT receive latency: the negotiated buffer that trades delay for loss + /// recovery. + pub latency: Duration, +} + +impl Default for Config { + fn default() -> Self { + Self { + listen: None, + prefix: String::new(), + latency: crate::server::DEFAULT_LATENCY, + } + } +} + +/// Run the SRT gateway until it fails, publishing `m=publish` connections into +/// `origin` and serving `m=request` connections out of it. +/// +/// This is the unauthenticated convenience entry point: it accepts every +/// publisher and subscriber and routes by [`prefix`](Config::prefix) + resource +/// name. Subscribe requests are served from `origin.consume()`, so anything in +/// the origin (SRT ingests and otherwise) can be pulled back out over SRT. To +/// gate access (e.g. verify the stream id as a JWT) or to scope the origin per +/// client, drive [`Server`] directly. +/// +/// Stays pending forever (rather than resolving) when SRT is disabled, so it +/// composes cleanly inside a `tokio::select!` alongside a relay's other +/// long-running tasks. +pub async fn run(origin: OriginProducer, config: Config) -> Result<()> { + let Some(listen) = config.listen else { + tracing::info!("SRT gateway disabled (no listen address)"); + std::future::pending::<()>().await; + unreachable!("pending future never resolves"); + }; + + let mut server = Server::bind(listen, config.latency).await?; + tracing::info!(%listen, prefix = %config.prefix, "SRT listening"); + + // Read side of the origin, used to serve `m=request` callers their broadcast. + let consumer = origin.consume(); + + // Tracks which broadcast paths are currently being ingested so a second + // publisher on the same stream id is rejected (first-publisher-wins, like an + // RTMP stream key) instead of being silently parked as a backup that could + // take over the path when the first publisher drops. + let active = ActivePaths::default(); + let prefix = Arc::new(config.prefix); + + while let Some(request) = server.accept().await { + let prefix = prefix.clone(); + match request { + Request::Publish(publish) => { + let origin = origin.clone(); + let active = active.clone(); + // Each connection runs on its own task: `accept` pumps media for the + // whole connection lifetime, so handling it inline would serialize + // publishers. + tokio::spawn(async move { + let peer = publish.peer(); + let path = format!("{prefix}{}", publish.resource()); + // Claim the path before accepting; the guard releases it when the + // connection task ends (success, error, or panic). + let Some(_guard) = active.claim(&path) else { + tracing::warn!(%peer, %path, "rejecting SRT publish: path already being ingested"); + let _ = publish.reject().await; + return; + }; + if let Err(err) = publish.accept(&origin, &path).await { + tracing::warn!(%peer, %path, %err, "SRT ingest ended with error"); + } else { + tracing::info!(%peer, %path, "SRT ingest ended"); + } + }); + } + Request::Subscribe(subscribe) => { + let consumer = consumer.clone(); + // Many viewers can request the same path concurrently, so subscribes + // don't claim an `ActivePaths` slot. + tokio::spawn(async move { + let peer = subscribe.peer(); + let path = format!("{prefix}{}", subscribe.resource()); + if let Err(err) = subscribe.accept(&consumer, &path).await { + tracing::warn!(%peer, %path, %err, "SRT request ended with error"); + } else { + tracing::info!(%peer, %path, "SRT request ended"); + } + }); + } + } + } + + Err(crate::Error::from(anyhow::anyhow!( + "SRT listener stopped accepting connections" + ))) +} + +/// The set of broadcast paths with a live ingest, used to reject duplicate +/// stream ids. Cheap to clone (shared `Arc`). +#[derive(Clone, Default)] +struct ActivePaths(Arc>>); + +impl ActivePaths { + /// Claim `path`, returning a guard that releases it on drop, or `None` if it + /// is already claimed. + fn claim(&self, path: &str) -> Option { + let mut set = self.0.lock().expect("active paths mutex poisoned"); + set.insert(path.to_string()).then(|| PathGuard { + paths: self.0.clone(), + path: path.to_string(), + }) + } +} + +/// Releases a claimed [`ActivePaths`] entry when dropped. +struct PathGuard { + paths: Arc>>, + path: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + self.paths + .lock() + .expect("active paths mutex poisoned") + .remove(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_paths_rejects_duplicates_and_releases_on_drop() { + let active = ActivePaths::default(); + + let guard = active.claim("live/cam0").expect("first claim succeeds"); + // A second claim of the same path is rejected while the first is held. + assert!(active.claim("live/cam0").is_none()); + // A different path is unaffected. + let other = active.claim("live/cam1").expect("distinct path claims"); + + // Dropping the guard releases the path so it can be reclaimed. + drop(guard); + assert!(active.claim("live/cam0").is_some()); + + drop(other); + } +} diff --git a/rs/moq-srt/src/server.rs b/rs/moq-srt/src/server.rs new file mode 100644 index 000000000..284ebd819 --- /dev/null +++ b/rs/moq-srt/src/server.rs @@ -0,0 +1,516 @@ +//! SRT server: accept connections, and hand each pending request to the caller +//! as a [`Request`] to authorize. +//! +//! [`Server::accept`] yields a [`Request`] for each incoming SRT connection, +//! before the handshake is finalized, classified by its stream-id `m=` mode into +//! one of two directions. The caller inspects [`Request::resource`] / +//! [`Request::stream_id`], makes an authorization decision, and either: +//! +//! - **[`Request::Publish`]**: [`Publish::accept`] (ingest the connection's +//! MPEG-TS into an origin at a path) or [`Publish::reject`]. This is the +//! contribution path (OBS, ffmpeg). +//! - **[`Request::Subscribe`]**: [`Subscribe::accept`] (re-mux a broadcast from +//! an origin back to MPEG-TS and stream it down to the caller) or +//! [`Subscribe::reject`]. This is the egress path: a player (VLC, ffmpeg) pulls +//! `srt://host:port?streamid=#!::r=,m=request`. +//! +//! This mirrors `moq-native`'s `Server` / `Request`, so the gateway stays +//! unopinionated about auth: the embedder (e.g. a relay verifying the stream id +//! as a JWT) owns that policy. For the unauthenticated convenience that accepts +//! everything and routes by prefix, use [`crate::run`]. + +use std::net::SocketAddr; +use std::time::{Duration, Instant}; + +use futures::{SinkExt, StreamExt}; +use moq_net::{OriginConsumer, OriginProducer}; +use srt_tokio::access::{ + AccessControlList, ConnectionMode, RejectReason, ServerRejectReason, StandardAccessControlEntry, +}; +use srt_tokio::options::StreamId; +use srt_tokio::{ConnectionRequest, SrtIncoming, SrtListener, SrtSocket}; + +use crate::Result; + +/// Default SRT receive latency: the negotiated buffer that trades delay for loss +/// recovery. Override per-server with [`Server::bind`]'s `latency` argument. +pub(crate) const DEFAULT_LATENCY: Duration = Duration::from_millis(200); + +/// SRT payload size for egress: 7 MPEG-TS packets (7 x 188), the de-facto +/// standard for TS-over-SRT and a clean fit under the typical SRT MTU. +const SRT_PAYLOAD: usize = 7 * 188; + +/// An SRT server that yields each incoming connection's pending request as a +/// [`Request`]. +/// +/// Build it with [`bind`](Self::bind), then loop on [`accept`](Self::accept). +/// Each [`Request`] is produced before the SRT handshake is finalized, so the +/// caller can authorize (and pick the broadcast path) before any media flows. +pub struct Server { + /// Held to keep the listener (and its UDP socket) alive for the server's lifetime. + _listener: SrtListener, + incoming: SrtIncoming, +} + +impl Server { + /// Bind an SRT listener on `addr` (SRT has no well-known port; 9000 is common). + /// + /// `latency` is the SRT receive latency, negotiated at handshake time; pass + /// `None` for a sensible default (200ms). + pub async fn bind(addr: SocketAddr, latency: impl Into>) -> Result { + let latency = latency.into().unwrap_or(DEFAULT_LATENCY); + let (listener, incoming) = SrtListener::builder().latency(latency).bind(addr).await?; + Ok(Self { + _listener: listener, + incoming, + }) + } + + /// Wait for the next connection that wants to publish or subscribe. + /// + /// Connections whose stream id can't be routed (no usable resource name) are + /// rejected internally and skipped, so every [`Request`] returned is + /// actionable. Returns `None` only if the listener stops accepting (it + /// currently never does). + pub async fn accept(&mut self) -> Option { + while let Some(request) = self.incoming.incoming().next().await { + let peer = request.remote(); + let Some((resource, mode)) = parse_stream_id(request.stream_id()) else { + tracing::warn!(%peer, stream_id = ?request.stream_id(), "rejecting SRT: no usable stream id"); + reject_log(request, ServerRejectReason::BadRequest, peer).await; + continue; + }; + + let stream_id = request.stream_id().map(|id| id.as_str().to_string()); + let pending = Pending { + request, + resource, + stream_id, + peer, + }; + + // `m=request` reads a broadcast out; everything else publishes one in. + return Some(match mode { + ConnectionMode::Request => Request::Subscribe(Subscribe(pending)), + _ => Request::Publish(Publish(pending)), + }); + } + + None + } +} + +/// Common state behind a pending [`Request`]: the SRT connection plus the +/// routing info parsed from its stream id. +struct Pending { + request: ConnectionRequest, + /// The resource name to route on: the stream id's `r=` value, or the raw + /// stream id when it carries no access-control list. + resource: String, + /// The raw stream id string, if any. Exposed so an embedder can parse its own + /// fields out of it (e.g. a token in `u=` or a custom key). + stream_id: Option, + peer: SocketAddr, +} + +/// What an accepted SRT connection wants: to contribute media ([`Publish`]) or to +/// view it ([`Subscribe`]). +/// +/// Yielded by [`Server::accept`], classified by the stream id's `m=` mode. +/// Inspect [`resource`](Self::resource) / [`stream_id`](Self::stream_id), then +/// match to authorize the right direction. Dropping it without accepting or +/// rejecting drops the connection. +#[non_exhaustive] +pub enum Request { + /// A client pushing media in (OBS, ffmpeg). Ingest it with [`Publish::accept`]. + Publish(Publish), + /// A client pulling media out (VLC, ffmpeg). Serve it with [`Subscribe::accept`]. + Subscribe(Subscribe), +} + +impl Request { + /// The resource name to route on: the stream id's `r=` value, or the raw + /// stream id when it carries no access-control list. + pub fn resource(&self) -> &str { + match self { + Request::Publish(r) => r.resource(), + Request::Subscribe(r) => r.resource(), + } + } + + /// The raw SRT stream id, if the client supplied one. + pub fn stream_id(&self) -> Option<&str> { + match self { + Request::Publish(r) => r.stream_id(), + Request::Subscribe(r) => r.stream_id(), + } + } + + /// The remote peer address. + pub fn peer(&self) -> SocketAddr { + match self { + Request::Publish(r) => r.peer(), + Request::Subscribe(r) => r.peer(), + } + } +} + +/// A pending SRT publish (contribution), waiting on the caller to authorize it. +/// +/// Inspect [`resource`](Self::resource) / [`stream_id`](Self::stream_id), then +/// either [`accept`](Self::accept) the publish into an origin at a chosen +/// broadcast path or [`reject`](Self::reject) it. Dropping it without either +/// drops the connection. +pub struct Publish(Pending); + +impl Publish { + /// The resource name to route on (the stream id's `r=` value, or the raw + /// stream id). + pub fn resource(&self) -> &str { + &self.0.resource + } + + /// The raw SRT stream id, if the client supplied one. + /// + /// Conventionally just a resource path, but an embedder can treat it (or a + /// field within it) as a token to authenticate the publish. + pub fn stream_id(&self) -> Option<&str> { + self.0.stream_id.as_deref() + } + + /// The remote peer address. + pub fn peer(&self) -> SocketAddr { + self.0.peer + } + + /// Accept the publish: announce a broadcast at `path` in `origin` and pump the + /// connection's MPEG-TS into it until the client disconnects. + /// + /// `origin` is whatever the caller wants the media published into (e.g. a + /// relay's shared origin, optionally scoped per the authenticated token). This + /// future resolves when the connection ends, so callers usually run it on its + /// own task. + pub async fn accept(self, origin: &OriginProducer, path: &str) -> Result<()> { + let socket = self.0.request.accept(None).await?; + tracing::info!(peer = %self.0.peer, %path, "SRT publish accepted"); + serve_publish(origin, path, socket).await + } + + /// Reject the publish, sending the client a `Forbidden` rejection. + pub async fn reject(self) -> Result<()> { + Ok(self + .0 + .request + .reject(RejectReason::Server(ServerRejectReason::Forbidden)) + .await?) + } +} + +/// A pending SRT subscribe (egress), waiting on the caller to authorize it. +/// +/// The viewing counterpart of [`Publish`]: inspect [`resource`](Self::resource) / +/// [`stream_id`](Self::stream_id), then [`accept`](Self::accept) to serve a +/// broadcast from an origin down to the caller, or [`reject`](Self::reject) it. +/// Dropping it without either drops the connection. +pub struct Subscribe(Pending); + +impl Subscribe { + /// The resource name to route on (the stream id's `r=` value, or the raw + /// stream id). + pub fn resource(&self) -> &str { + &self.0.resource + } + + /// The raw SRT stream id, if the client supplied one. + /// + /// As with a publish, an embedder can treat this as a token to authorize the + /// viewer. + pub fn stream_id(&self) -> Option<&str> { + self.0.stream_id.as_deref() + } + + /// The remote peer address. + pub fn peer(&self) -> SocketAddr { + self.0.peer + } + + /// Accept the subscribe: resolve the broadcast at `path` in `origin`, re-mux + /// it to MPEG-TS, and stream it down to the caller until either side ends. + /// + /// Waits for the broadcast to be announced (so a caller may connect before the + /// publisher), cancelling cleanly if the caller disconnects first. This future + /// resolves when playback ends, so callers usually run it on its own task. + pub async fn accept(self, origin: &OriginConsumer, path: &str) -> Result<()> { + let socket = self.0.request.accept(None).await?; + tracing::info!(peer = %self.0.peer, %path, "SRT subscribe accepted"); + serve_subscribe(origin, path, socket).await + } + + /// Reject the subscribe, sending the client a `Forbidden` rejection. + pub async fn reject(self) -> Result<()> { + Ok(self + .0 + .request + .reject(RejectReason::Server(ServerRejectReason::Forbidden)) + .await?) + } +} + +/// Reject a connection request, logging (but not propagating) a send failure. +/// Used for connections the server drops itself, before they reach the caller. +async fn reject_log(request: ConnectionRequest, reason: ServerRejectReason, peer: SocketAddr) { + if let Err(err) = request.reject(RejectReason::Server(reason)).await { + tracing::debug!(%peer, %err, "failed to send SRT rejection"); + } +} + +/// Pump one accepted SRT socket's MPEG-TS payload into the origin (`m=publish`). +async fn serve_publish(origin: &OriginProducer, path: &str, mut socket: SrtSocket) -> Result<()> { + use futures::TryStreamExt; + + let mut publisher = crate::ts::Publisher::new(origin, path)?; + while let Some((_instant, bytes)) = socket.try_next().await? { + publisher.feed(bytes)?; + } + publisher.finish()?; + Ok(()) +} + +/// Mux the requested broadcast back to MPEG-TS and stream it to the SRT caller +/// (`m=request`). +/// +/// Waits for the broadcast to be announced (so a caller may connect before the +/// publisher), then packs the muxer's output into [`SRT_PAYLOAD`]-sized SRT +/// messages. Returns once the broadcast ends or the caller disconnects. +async fn serve_subscribe(origin: &OriginConsumer, path: &str, mut socket: SrtSocket) -> Result<()> { + // Resolve the broadcast, but watch the socket while we wait: `announced_broadcast` + // parks forever for a stream that is never published, and nothing else polls the + // socket during that wait, so without this a caller who requests a non-existent + // stream (or hangs up before it starts) would leak this task and its socket. + let subscriber = tokio::select! { + biased; + _ = wait_closed(&mut socket) => { + tracing::debug!(%path, "SRT subscribe closed before its broadcast was available"); + return Ok(()); + } + subscriber = crate::ts::Subscriber::new(origin, path) => subscriber?, + }; + + let Some(mut subscriber) = subscriber else { + tracing::warn!(%path, "SRT subscribe for an unroutable broadcast"); + return Ok(()); + }; + + // MPEG-TS is a continuous byte stream, so we coalesce the muxer's per-frame + // output and slice it on a fixed boundary rather than preserving frames. + // + // Pace each payload on the media clock: the Instant handed to `send` is the + // payload's origin time feeding the receiver's TSBPD, which reconstructs the + // inter-frame spacing from it. We don't know the live playhead when a subscriber + // attaches, so `pace` anchors it for us -- the newest frame is "now" and earlier + // frames map to proportionally earlier instants, re-anchoring whenever the media + // outruns wall-clock (a tune-in burst, a catch-up, or producer drift). `anchor` + // and `base` are that media-clock anchor (`base`'s media time maps to `anchor`), + // carried across frames and moved forward by `pace`. + let mut anchor = Instant::now(); + let mut base = None; + let mut send_at = anchor; + let mut buffer = bytes::BytesMut::new(); + while let Some(frame) = subscriber.next().await? { + // The media zero-point the rest pace against; `pace` re-anchors it forward to + // the live edge whenever the media outruns wall-clock. + let zero = *base.get_or_insert(frame.timestamp); + let paced = pace(anchor, zero, frame.timestamp, Instant::now()); + send_at = paced.send_at; + anchor = paced.anchor; + base = Some(paced.base); + + buffer.extend_from_slice(&frame.payload); + while buffer.len() >= SRT_PAYLOAD { + socket.send((send_at, buffer.split_to(SRT_PAYLOAD).freeze())).await?; + } + } + + if !buffer.is_empty() { + socket.send((send_at, buffer.freeze())).await?; + } + socket.close().await?; + + Ok(()) +} + +/// One frame's SRT send time plus the media-clock anchor to carry into the next +/// frame. `base`'s media time maps to `anchor` on the wall clock. +struct Paced { + /// The Instant to stamp this payload with: its TSBPD origin time. + send_at: Instant, + anchor: Instant, + base: moq_mux::container::Timestamp, +} + +/// Pace one SRT payload on the media clock, re-anchored to the live edge. +/// +/// `send_at = anchor + (ts - base)` stamps the payload at its media time, so the +/// receiver's TSBPD reconstructs inter-frame spacing from it. But when that runs +/// ahead of `now` -- the media clock has outrun wall-clock: a subscriber tuning in +/// bursts the current GOP plus any catch-up backlog (frames whose media timestamps +/// span seconds, produced within milliseconds), or the producer drifts faster than +/// real time -- re-anchor the live edge to `now` (this frame becomes the playhead). +/// Otherwise the payload is stamped seconds into the future, where SRT holds it in +/// the sender past the receiver's TSBPD latency window and playback stalls after +/// ~one packet. +/// +/// Re-anchoring only ever moves the anchor *forward*, so a frame that merely arrives +/// late -- network/CPU jitter, or a reordered B-frame whose PTS trails the edge -- +/// keeps its earlier media instant instead of collapsing to its arrival instant, and +/// TSBPD still smooths the jitter into even playout. The cap is "never lead `now`": +/// the receiver owns the jitter buffer (the SRT latency parameter), so the sender +/// adds no lookahead of its own (a deliberate lead would just be `now + lead` here). +fn pace( + anchor: Instant, + base: moq_mux::container::Timestamp, + ts: moq_mux::container::Timestamp, + now: Instant, +) -> Paced { + let send_at = anchor + Duration::from(ts).saturating_sub(Duration::from(base)); + if send_at > now { + // Media outran wall-clock: re-anchor so this newest frame is the live edge. + Paced { + send_at: now, + anchor: now, + base: ts, + } + } else { + Paced { send_at, anchor, base } + } +} + +/// Resolve once the SRT caller hangs up (a clean close or an error), draining and +/// ignoring any unexpected inbound packets. A subscribe caller normally sends +/// nothing, so this is purely a disconnect signal to race against the announce wait. +async fn wait_closed(socket: &mut SrtSocket) { + use futures::TryStreamExt; + while let Ok(Some(_)) = socket.try_next().await {} +} + +/// Parse an SRT stream id into its resource name and connection mode. +/// +/// Prefers the standard `#!::r=,m=` form, then falls back to the +/// raw stream-id string (always treated as publish). Returns `None` when there's +/// nothing usable to route on. +fn parse_stream_id(stream_id: Option<&StreamId>) -> Option<(String, ConnectionMode)> { + let raw = stream_id?.as_str().trim(); + + // Standard SRT access-control form: `#!::r=,m=,...`. Absent + // `m=` defaults to publish, matching a bare stream id and OBS-style ingest. + let mut resource = None; + let mut mode = ConnectionMode::Publish; + if let Ok(acl) = raw.parse::() { + for entry in acl.0 { + match StandardAccessControlEntry::try_from(entry) { + Ok(StandardAccessControlEntry::ResourceName(name)) if !name.is_empty() => resource = Some(name), + Ok(StandardAccessControlEntry::Mode(m)) => mode = m, + _ => {} + } + } + } + + // Fall back to the raw stream id (e.g. OBS-style `app/key`), but never to an + // unparsed `#!::` control string. + let name = match resource { + Some(name) => name, + None if raw.is_empty() || raw.starts_with("#!::") => return None, + None => raw.to_string(), + }; + + Some((name, mode)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pace_re_anchors_to_live_edge() { + use moq_mux::container::Timestamp; + use std::time::{Duration, Instant}; + let ms = |m: u64| Timestamp::from_micros(m * 1_000).unwrap(); + + // Tune-in burst: the live edge (4132ms of media) is produced ~8ms after the + // first frame (1400ms). It must re-anchor to `now` rather than stamp ~2.7s + // into the future, which would stall the receiver's TSBPD after ~one payload. + let start = Instant::now(); + let now = start + Duration::from_millis(8); + let edge = pace(start, ms(1_400), ms(4_132), now); + assert_eq!(edge.send_at, now, "live edge should pace to now"); + assert_eq!(edge.anchor, now); + assert_eq!( + edge.base.as_micros(), + ms(4_132).as_micros(), + "anchor moves up to the live edge" + ); + + // Part 1 moved the anchor to the live edge (now / 4132ms). A frame 33ms newer in + // MEDIA that arrives 80ms later in WALL-clock (jitter) paces from that carried- + // forward anchor -- its media instant (+33ms off the edge), not its 80ms arrival + // instant -- so TSBPD still reconstructs smooth spacing. + let jittered = pace( + edge.anchor, + edge.base, + ms(4_165), + edge.anchor + Duration::from_millis(80), + ); + assert_eq!( + jittered.send_at, + edge.anchor + Duration::from_millis(33), + "a late frame keeps its media instant, not its arrival instant" + ); + assert_eq!( + jittered.anchor, edge.anchor, + "no re-anchor when media is behind wall-clock" + ); + } + + fn sid(s: &str) -> StreamId { + StreamId::try_from(s.as_bytes().to_vec()).unwrap() + } + + fn parse(s: &str) -> Option<(String, ConnectionMode)> { + parse_stream_id(Some(&sid(s))) + } + + #[test] + fn standard_resource_form() { + let (resource, mode) = parse("#!::r=live/cam0,m=publish").unwrap(); + assert_eq!(resource, "live/cam0"); + assert_eq!(mode, ConnectionMode::Publish); + } + + #[test] + fn request_mode_is_egress() { + let (resource, mode) = parse("#!::r=live/cam0,m=request").unwrap(); + assert_eq!(resource, "live/cam0"); + assert_eq!(mode, ConnectionMode::Request); + } + + #[test] + fn absent_mode_defaults_to_publish() { + // Both a bare stream id and an `r=`-only ACL ingest by default. + assert_eq!(parse("app/key").unwrap().1, ConnectionMode::Publish); + assert_eq!(parse("#!::r=cam0").unwrap().1, ConnectionMode::Publish); + } + + #[test] + fn raw_stream_id() { + let (resource, mode) = parse("app/key").unwrap(); + assert_eq!(resource, "app/key"); + assert_eq!(mode, ConnectionMode::Publish); + } + + #[test] + fn missing_or_empty_is_rejected() { + assert!(parse_stream_id(None).is_none()); + assert!(parse("").is_none()); + assert!(parse("#!::").is_none()); + } +} diff --git a/rs/moq-srt/src/ts.rs b/rs/moq-srt/src/ts.rs new file mode 100644 index 000000000..22e1c2d8c --- /dev/null +++ b/rs/moq-srt/src/ts.rs @@ -0,0 +1,94 @@ +//! The seam between an SRT byte stream and the MoQ origin. +//! +//! SRT carries MPEG-TS, so ingest is the same three steps every time: create a +//! broadcast, publish it into the origin so downstream subscribers can find it, +//! and feed the incoming bytes through a [`moq_mux`] TS importer that demuxes +//! them into MoQ tracks. [`Publisher`] packages that up. [`Subscriber`] is the +//! mirror image for egress: it consumes a broadcast from the origin and re-muxes +//! it back to MPEG-TS for an SRT caller (VLC, ffmpeg) to play. + +use bytes::Bytes; +use moq_mux::catalog::hang::Extra; +use moq_mux::container::{Frame, ts}; +use moq_net::{Broadcast, OriginConsumer, OriginProducer}; + +use crate::Result; + +/// Publishes an MPEG-TS source into the origin as a single broadcast. +/// +/// Each chunk is handed straight to the TS importer, which consumes whole +/// transport packets and retains any partial trailing packet internally for the +/// next call (the same pattern `moq-cli publish ts` uses against stdin). +/// Dropping the publisher ends the broadcast: the importer's producer clone +/// closes, which unannounces it from the origin. +pub struct Publisher { + /// Owns a clone of the broadcast producer, so the broadcast stays announced + /// (and writable) for the publisher's lifetime. + importer: ts::Import, +} + +impl Publisher { + /// Create the broadcast, wire up the TS importer + catalog, and announce it + /// into `origin` at `path`. + pub fn new(origin: &OriginProducer, path: &str) -> Result { + let mut broadcast = Broadcast::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast)?; + let importer = ts::Import::new(broadcast.clone(), catalog); + + // The origin unannounces the path automatically when the broadcast closes, + // i.e. when this importer (the last producer clone) is dropped. + if !origin.publish_broadcast(path, broadcast.consume()) { + return Err(crate::Error::from(anyhow::anyhow!( + "not allowed to publish broadcast at {path}" + ))); + } + tracing::info!(%path, "publishing ingest broadcast"); + + Ok(Self { importer }) + } + + /// Feed a chunk of MPEG-TS bytes (one SRT payload) into the importer. + /// + /// `decode` drains `data` fully, buffering any partial trailing packet in + /// its own internal scratch, so there's nothing to retain here. + pub fn feed(&mut self, mut data: Bytes) -> Result<()> { + Ok(self.importer.decode(&mut data)?) + } + + /// Flush any buffered media and close out the broadcast's open groups. + pub fn finish(&mut self) -> Result<()> { + Ok(self.importer.finish()?) + } +} + +/// Muxes a single MoQ broadcast back into an MPEG-TS byte stream for egress. +/// +/// The mirror of [`Publisher`]: where that demuxes SRT-carried TS into the +/// origin, this consumes a broadcast from the origin and re-muxes it to TS so an +/// SRT caller can play it. Pull frames with [`next`](Self::next); each carries +/// the TS bytes plus the media timestamp used to pace delivery. +pub struct Subscriber { + export: ts::Export, +} + +impl Subscriber { + /// Resolve the broadcast at `path` in the origin and prepare to mux it to TS. + /// + /// Returns `Ok(None)` if the broadcast can never be served (path outside the + /// consumer's scope, or the origin closed). Otherwise waits for the broadcast + /// to be announced, so a caller may connect before the publisher does. + pub async fn new(origin: &OriginConsumer, path: &str) -> Result> { + let Some(broadcast) = origin.announced_broadcast(path).await else { + return Ok(None); + }; + + let export = ts::Export::new(broadcast)?; + Ok(Some(Self { export })) + } + + /// Pull the next muxed frame (TS bytes + media timestamp), or `None` once the + /// broadcast ends. + pub async fn next(&mut self) -> Result> { + Ok(self.export.next().await?) + } +} From 2af977779c5bea3c3b1c4ac7c892c8bf907a01fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Jun 2026 07:44:32 -0700 Subject: [PATCH 12/34] feat(moq-rtc): add WebRTC (WHIP/WHEP) gateway (#1916) Co-authored-by: Claude Opus 4.8 --- Cargo.lock | 205 +++++++++++- Cargo.toml | 2 + doc/.vitepress/config.ts | 1 + doc/bin/index.md | 6 + doc/bin/rtc.md | 120 +++++++ rs/CLAUDE.md | 1 + rs/moq-mux/src/codec/annexb.rs | 51 ++- rs/moq-mux/src/codec/h264/mod.rs | 52 ++- rs/moq-mux/src/codec/h265/mod.rs | 61 ++++ rs/moq-rtc/Cargo.toml | 67 ++++ rs/moq-rtc/bin/moq-rtc.rs | 263 +++++++++++++++ rs/moq-rtc/src/client/mod.rs | 63 ++++ rs/moq-rtc/src/client/whep.rs | 73 ++++ rs/moq-rtc/src/client/whip.rs | 79 +++++ rs/moq-rtc/src/codec/h264.rs | 34 ++ rs/moq-rtc/src/codec/mod.rs | 333 ++++++++++++++++++ rs/moq-rtc/src/codec/opus.rs | 39 +++ rs/moq-rtc/src/codec/vp8.rs | 65 ++++ rs/moq-rtc/src/codec/vp9.rs | 119 +++++++ rs/moq-rtc/src/egress.rs | 233 +++++++++++++ rs/moq-rtc/src/error.rs | 36 ++ rs/moq-rtc/src/ingest.rs | 64 ++++ rs/moq-rtc/src/lib.rs | 53 +++ rs/moq-rtc/src/sdp.rs | 36 ++ rs/moq-rtc/src/server/mod.rs | 214 ++++++++++++ rs/moq-rtc/src/server/mux.rs | 195 +++++++++++ rs/moq-rtc/src/server/whep.rs | 153 +++++++++ rs/moq-rtc/src/server/whip.rs | 157 +++++++++ rs/moq-rtc/src/session.rs | 557 +++++++++++++++++++++++++++++++ rs/moq-rtc/tests/bitstream.rs | 212 ++++++++++++ 30 files changed, 3523 insertions(+), 21 deletions(-) create mode 100644 doc/bin/rtc.md create mode 100644 rs/moq-rtc/Cargo.toml create mode 100644 rs/moq-rtc/bin/moq-rtc.rs create mode 100644 rs/moq-rtc/src/client/mod.rs create mode 100644 rs/moq-rtc/src/client/whep.rs create mode 100644 rs/moq-rtc/src/client/whip.rs create mode 100644 rs/moq-rtc/src/codec/h264.rs create mode 100644 rs/moq-rtc/src/codec/mod.rs create mode 100644 rs/moq-rtc/src/codec/opus.rs create mode 100644 rs/moq-rtc/src/codec/vp8.rs create mode 100644 rs/moq-rtc/src/codec/vp9.rs create mode 100644 rs/moq-rtc/src/egress.rs create mode 100644 rs/moq-rtc/src/error.rs create mode 100644 rs/moq-rtc/src/ingest.rs create mode 100644 rs/moq-rtc/src/lib.rs create mode 100644 rs/moq-rtc/src/sdp.rs create mode 100644 rs/moq-rtc/src/server/mod.rs create mode 100644 rs/moq-rtc/src/server/mux.rs create mode 100644 rs/moq-rtc/src/server/whep.rs create mode 100644 rs/moq-rtc/src/server/whip.rs create mode 100644 rs/moq-rtc/src/session.rs create mode 100644 rs/moq-rtc/tests/bitstream.rs diff --git a/Cargo.lock b/Cargo.lock index 586a461f5..ff02389c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,7 +259,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 2.0.18", @@ -849,6 +849,18 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -861,7 +873,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1236,6 +1248,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1582,6 +1609,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "der_derive", + "flagset", "pem-rfc7468 0.7.0", "zeroize", ] @@ -1605,12 +1634,23 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1724,6 +1764,31 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dimpl" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7afb6878ee6941d3ee770bd8a391c0c083ee2102a7e8e91a730fb722ef1e46b9" +dependencies = [ + "aes", + "arrayvec", + "aws-lc-rs", + "ccm", + "der 0.7.10", + "log", + "nom 8.0.0", + "once_cell", + "pkcs8 0.10.2", + "rand 0.9.4", + "rcgen", + "sec1", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "time", + "x509-cert", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -2051,6 +2116,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -3458,6 +3529,19 @@ dependencies = [ "ws_stream_wasm", ] +[[package]] +name = "is" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840878b6e30d40e5bda1a7116100f1a18b7bdb91814513b87be80bbfb5d41879" +dependencies = [ + "crc", + "serde", + "str0m-proto", + "subtle", + "tracing", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -3805,7 +3889,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f03cd3335fb5f2447755d45cda9c70f76013626a9db44374973791b0926a86c3" dependencies = [ "chrono", - "nom", + "nom 7.1.3", ] [[package]] @@ -4209,6 +4293,31 @@ dependencies = [ "wiremock", ] +[[package]] +name = "moq-rtc" +version = "0.0.1" +dependencies = [ + "anyhow", + "axum", + "axum-server", + "bytes", + "clap", + "hang", + "moq-mux", + "moq-native", + "moq-net", + "reqwest 0.12.28", + "rustls", + "sd-notify", + "str0m", + "thiserror 2.0.18", + "tokio", + "tower-http", + "tracing", + "url", + "uuid", +] + [[package]] name = "moq-rtmp" version = "0.0.1" @@ -4587,6 +4696,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonzero_ext" version = "0.3.0" @@ -6315,7 +6433,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -6528,6 +6646,21 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "sctp-proto" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8423ea59db998985015bc5d0145837eab48f60ec449a2dc01f5870499afe0a4" +dependencies = [ + "bytes", + "crc", + "log", + "rand 0.9.4", + "rustc-hash", + "slab", + "thiserror 2.0.18", +] + [[package]] name = "scuffle-av1" version = "0.1.4" @@ -7201,6 +7334,53 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str0m" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431befc786d98bfa860118d96890df038e4db51635494203bbe600b05582f920" +dependencies = [ + "arrayvec", + "base64ct", + "combine", + "dimpl", + "fastrand", + "is", + "sctp-proto", + "serde", + "str0m-aws-lc-rs", + "str0m-proto", + "subtle", + "time", + "tracing", +] + +[[package]] +name = "str0m-aws-lc-rs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1908a6439b68fd22c275d44cbf4b50b2a75cc45f2b626160d87a194023c18fcd" +dependencies = [ + "aws-lc-rs", + "dimpl", + "str0m-proto", + "time", +] + +[[package]] +name = "str0m-proto" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02836118cf7384413d7e8beb8b9ab56a4007d1b6514c11df35150a2e7aef8f1a" +dependencies = [ + "base64ct", + "dimpl", + "fastrand", + "serde", + "subtle", + "time", +] + [[package]] name = "streaming-stats" version = "0.2.3" @@ -8630,7 +8810,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -9118,6 +9298,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -9129,7 +9320,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "ring", "rusticata-macros", diff --git a/Cargo.toml b/Cargo.toml index 999979cbf..cf248294c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "rs/moq-native", "rs/moq-net", "rs/moq-relay", + "rs/moq-rtc", "rs/moq-rtmp", "rs/moq-srt", "rs/moq-token", @@ -41,6 +42,7 @@ default-members = [ "rs/moq-mux", "rs/moq-native", "rs/moq-relay", + "rs/moq-rtc", "rs/moq-rtmp", "rs/moq-srt", "rs/moq-token", diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 3e73bc619..440e64e5d 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -137,6 +137,7 @@ export default defineConfig({ ], }, { text: "CLI", link: "/bin/cli" }, + { text: "WebRTC", link: "/bin/rtc" }, { text: "RTMP", link: "/bin/rtmp" }, { text: "OBS", link: "/bin/obs" }, { text: "GStreamer", link: "/bin/gstreamer" }, diff --git a/doc/bin/index.md b/doc/bin/index.md index 17ea3759f..0aa79c9b7 100644 --- a/doc/bin/index.md +++ b/doc/bin/index.md @@ -29,6 +29,12 @@ Another tool does the encoding (ex. ffmpeg), making it easy to pipe any media in ffmpeg -f avfoundation -i "0" -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` +## [moq-rtc](/bin/rtc) + +A WebRTC <-> MoQ gateway. Speaks WHIP (publish) and WHEP (subscribe) in either +HTTP role, so it can accept incoming peers (OBS, browsers) or dial out to a +remote WebRTC server. Ingest and egress both work for H.264, VP8, VP9, and Opus. + ## [moq-rtmp](/bin/rtmp) An RTMP / enhanced-RTMP -> MoQ ingest gateway. Accepts RTMP from any encoder diff --git a/doc/bin/rtc.md b/doc/bin/rtc.md new file mode 100644 index 000000000..c764c84c2 --- /dev/null +++ b/doc/bin/rtc.md @@ -0,0 +1,120 @@ +--- +title: moq-rtc +description: WebRTC <-> MoQ gateway (WHIP/WHEP) +--- + +# moq-rtc + +`moq-rtc` bridges WebRTC and Media over QUIC. It speaks +[WHIP](https://datatracker.ietf.org/doc/html/rfc9725) (publish) and WHEP +(subscribe) in **either HTTP role**, so it can either accept incoming peers +or dial out to a remote WebRTC server. + +## The 2x2 + +| Subcommand | WebRTC role | Direction | Status | +|---|---|---|---| +| `server publish` | accept WHIP publishes | RTP into MoQ | working | +| `client subscribe` | dial a remote WHEP URL | RTP into MoQ | working | +| `server subscribe` | serve WHEP subscriptions | MoQ -> RTP | working | +| `client publish` | dial a remote WHIP URL | MoQ -> RTP | working | + +All four paths work. The egress paths use str0m's Frame API to packetize +MoQ frames back into RTP; the per-codec adapters live in `codec::Track` +and are the same shape regardless of HTTP role. + +### Keyframe latency on the egress side + +A freshly-connected WHEP / WHIP-out peer subscribes at the *current* +(in-progress) MoQ group, which begins at a keyframe, so it gets a +decodable start without waiting for the next GOP boundary. If the peer +loses keyframe packets, str0m fulfils its NACK retransmissions from the +video send buffer, which is sized to cover a large keyframe plus the rest +of the current group. MoQ has no PLI path back to the publisher, so +`KeyframeRequest` (PLI/FIR) events from the peer are logged but not +propagated upstream. + +The egress paths (WHEP server, WHIP client) negotiate H.264, H.265, VP8, +VP9, AV1, and Opus. The ingest paths (WHIP server, WHEP client) currently +accept H.264, VP8, VP9, and Opus; H.265 / AV1 ingest is a follow-up. + +## CLI shape + +Mirrors `moq-cli`: globals first, then HTTP role, then direction. + +```bash +# server publish (WHIP server): accept publishes into MoQ +moq-rtc --relay https://relay.example.com --broadcast my-stream \ + server --listen 0.0.0.0:8088 publish + +# client subscribe (WHEP client): pull from a remote WHEP source +moq-rtc --relay https://relay.example.com --broadcast cam0 \ + client --url https://camera.example.com/whep/cam0 subscribe + +# server subscribe (WHEP server): serve a MoQ broadcast over WHEP +moq-rtc --relay https://relay.example.com --broadcast my-stream \ + server --listen 0.0.0.0:8088 subscribe + +# client publish (WHIP client): push a MoQ broadcast to a remote WHIP endpoint +moq-rtc --relay https://relay.example.com --broadcast my-stream \ + client --url https://twitch.tv/whip publish +``` + +### Global flags + +- `--relay`: upstream MoQ relay to publish to / subscribe from. +- `--broadcast`: MoQ broadcast name this gateway binds to. +- `--public-addr`: optional public UDP socket address(es) to advertise as + ICE host candidates. Repeat the flag (or comma-separate) for dual-stack + IPv4 + IPv6 deployments. When empty, str0m discovers peer-reflexive + candidates via STUN binding requests, which works for most NAT + scenarios. + +### Server flags + +- `--listen`: HTTP bind address (default `[::]:8088`). +- `--udp-bind`: UDP address the shared WebRTC media socket binds to + (default `0.0.0.0:0`, i.e. an OS-picked port for dev/loopback). Every + WHIP/WHEP session shares this one port (demuxed by ICE ufrag), so a + deployment behind a firewall pins it (e.g. `0.0.0.0:8089`) and opens just + that one media port. Pair it with `--public-addr` so the advertised ICE + candidate uses the pinned port. +- `--tls-cert` / `--tls-key`: serve HTTPS instead. Most WHIP clients + require it in practice. + +### Client flags + +- `--url`: remote WHIP or WHEP resource URL. + +### Session teardown + +The bundled WHIP/WHEP servers honor an HTTP `DELETE` to the resource URL +returned in the `Location` header (`//`), per +RFC 9725. It ends the session promptly, releasing its broadcast +announcement and shared-media-port registration instead of waiting for the +ICE disconnect timeout. Embedders that own their own routing can call +`Server::terminate(resource_id)` to do the same. + +## Codec mapping + +| WebRTC codec | MoQ catalog | Egress | Ingest | +|--------------|-------------|--------|--------| +| Opus | `AudioCodec::Opus`, 48 kHz / stereo | yes | yes | +| H.264 | `VideoCodec::H264` (avc3 inline or avc1 + `avcC`) | yes | yes (avc3) | +| H.265 | `VideoCodec::H265` (hev1 inline or hvc1 + `hvcC`) | yes | no | +| VP8 | `VideoCodec::VP8` | yes | yes | +| VP9 | `VideoCodec::VP9` | yes | yes | +| AV1 | `VideoCodec::AV1` | yes | no | + +On egress, `codec::Track` reshapes each rendition into what str0m's Frame +API expects. Opus / VP8 / VP9 / AV1 and inline-parameter H.264 (avc3) / +H.265 (hev1) pass through untouched. Out-of-band-parameter H.264 (avc1) and +H.265 (hvc1) are rewritten from length-prefixed NALU to Annex-B with the +parameter sets (SPS/PPS, plus VPS for H.265) prepended to each keyframe. +This reuses `moq-mux`'s `h264::Avcc::parse` / `h265::Hvcc::parse` and `annexb` +helpers, the same logic its own avc1/hvc1 transmuxers use. + +On ingest, H.264 is reassembled by str0m as Annex-B; `moq-mux`'s H.264 +importer in `Avc3` mode publishes the inline-parameter shape directly, which +lines up with what the WebCodecs decoder in `@moq/watch` already expects. No +extra conversion needed in the gateway. diff --git a/rs/CLAUDE.md b/rs/CLAUDE.md index f8b991b79..b9df6160c 100644 --- a/rs/CLAUDE.md +++ b/rs/CLAUDE.md @@ -28,6 +28,7 @@ Layered roughly transport -> container/format -> media -> apps/bindings. **Apps / binaries** - `moq-relay` (lib+bin): clusterable, media-agnostic relay. axum HTTP API, JWT auth, WebSocket fallback, clustering. Config/TOML merge pattern lives here (see below). - `moq-cli` (lib+bin, `moq`): serve/accept/publish/subscribe; stdin/stdout media piping. +- `moq-rtc` (lib+bin): WebRTC (WHIP/WHEP) gateway. Bridges browser WebRTC ingest/playback to MoQ broadcasts (str0m ICE/DTLS, A/V sync, NACK). Embeddable lib (`default-features = false`) + standalone binary (`server` feature). - `moq-bench` (bin): relay load generator. `JoinSet`-spawned staggered connections, rand sampling. - `moq-boy` (bin): crowd-controlled Game Boy emulator publisher (blocking emulator thread + async monitor tasks). - `moq-token` (lib) / `moq-token-cli` (bin): JWT auth. `Claims`, `Algorithm`, `KeyType` (EC/RSA/OCT/OKP), JWKS. CLI does generate/sign/verify. diff --git a/rs/moq-mux/src/codec/annexb.rs b/rs/moq-mux/src/codec/annexb.rs index 4ae1a86ad..8758ece65 100644 --- a/rs/moq-mux/src/codec/annexb.rs +++ b/rs/moq-mux/src/codec/annexb.rs @@ -1,4 +1,4 @@ -use anyhow::{self}; +use anyhow::Context; use bytes::{Buf, Bytes, BytesMut}; pub const START_CODE: Bytes = Bytes::from_static(&[0, 0, 0, 1]); @@ -148,6 +148,55 @@ pub fn find_start_code(b: &[u8]) -> Option<(usize, usize)> { } } +/// Convert a length-prefixed NALU payload (avc1 / hvc1 wire shape) to Annex-B, +/// optionally prepending `prefix` bytes (typically VPS/SPS/PPS NAL units already +/// in Annex-B form, for keyframe parameter-set injection). +pub fn from_length_prefixed(payload: &[u8], length_size: usize, prefix: Option<&[u8]>) -> anyhow::Result { + anyhow::ensure!( + (1..=4).contains(&length_size), + "invalid avc1/hvc1 length size {length_size}" + ); + + let mut out = BytesMut::with_capacity(payload.len() + prefix.map(|p| p.len()).unwrap_or(0) + 16); + if let Some(p) = prefix { + out.extend_from_slice(p); + } + + let mut pos = 0; + while pos < payload.len() { + let after_prefix = pos + .checked_add(length_size) + .context("truncated length-prefixed NAL unit")?; + anyhow::ensure!(payload.len() >= after_prefix, "truncated length-prefixed NAL unit"); + let mut len = 0usize; + for byte in &payload[pos..after_prefix] { + len = (len << 8) | (*byte as usize); + } + let after_nal = after_prefix + .checked_add(len) + .context("truncated length-prefixed NAL unit")?; + anyhow::ensure!(payload.len() >= after_nal, "truncated length-prefixed NAL unit"); + out.extend_from_slice(&START_CODE); + out.extend_from_slice(&payload[after_prefix..after_nal]); + pos = after_nal; + } + + Ok(out.freeze()) +} + +/// Concatenate `start_code | nal` for every NAL in `nals` and freeze the result. +/// Used to build a keyframe parameter-set prefix for an Annex-B elementary stream. +pub fn build_prefix<'a, I: IntoIterator>(nals: I) -> Bytes { + let nals: Vec<&Bytes> = nals.into_iter().collect(); + let total: usize = nals.iter().map(|n| n.len() + START_CODE.len()).sum(); + let mut out = BytesMut::with_capacity(total); + for nal in nals { + out.extend_from_slice(&START_CODE); + out.extend_from_slice(nal); + } + out.freeze() +} + #[cfg(test)] mod tests { use super::*; diff --git a/rs/moq-mux/src/codec/h264/mod.rs b/rs/moq-mux/src/codec/h264/mod.rs index 44e918a6d..0185159de 100644 --- a/rs/moq-mux/src/codec/h264/mod.rs +++ b/rs/moq-mux/src/codec/h264/mod.rs @@ -53,12 +53,17 @@ impl Sps { /// avcC bytes are still what gets stored as the catalog `description`; this /// struct is for the field extraction. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct Avcc { pub profile: u8, pub constraints: u8, pub level: u8, /// NALU length size in bytes (typically 4). pub length_size: usize, + /// SPS NAL units carried out-of-band in the record. + pub sps: Vec, + /// PPS NAL units carried out-of-band in the record. + pub pps: Vec, /// Resolution from the embedded SPS, if one was present and parseable. pub coded_width: Option, pub coded_height: Option, @@ -67,26 +72,31 @@ pub struct Avcc { impl Avcc { /// Parse an AVCDecoderConfigurationRecord buffer. pub fn parse(avcc: &[u8]) -> anyhow::Result { - anyhow::ensure!(avcc.len() >= 6, "AVCDecoderConfigurationRecord too short"); + anyhow::ensure!(avcc.len() >= 7, "AVCDecoderConfigurationRecord too short"); let profile = avcc[1]; let constraints = avcc[2]; let level = avcc[3]; let length_size = (avcc[4] & 0x03) as usize + 1; - let num_sps = avcc[5] & 0x1f; + let num_sps = (avcc[5] & 0x1f) as usize; + let mut sps = Vec::with_capacity(num_sps); + let mut pos = read_param_set_array(avcc, 6, num_sps, &mut sps)?; + + anyhow::ensure!(avcc.len() > pos, "AVCDecoderConfigurationRecord truncated"); + let num_pps = avcc[pos] as usize; + pos += 1; + let mut pps = Vec::with_capacity(num_pps); + read_param_set_array(avcc, pos, num_pps, &mut pps)?; + + // Resolution from the first parseable SPS. let (mut coded_width, mut coded_height) = (None, None); - if num_sps > 0 && avcc.len() >= 8 { - let sps_len = u16::from_be_bytes([avcc[6], avcc[7]]) as usize; - let sps_start = 8; - let sps_end = sps_start + sps_len; - if sps_end <= avcc.len() - && sps_len > 1 - && let Ok(sps) = Sps::parse(&avcc[sps_start..sps_end]) - { - coded_width = Some(sps.coded_width); - coded_height = Some(sps.coded_height); - } + if let Some(first) = sps.first() + && first.len() > 1 + && let Ok(parsed) = Sps::parse(first) + { + coded_width = Some(parsed.coded_width); + coded_height = Some(parsed.coded_height); } Ok(Self { @@ -94,6 +104,8 @@ impl Avcc { constraints, level, length_size, + sps, + pps, coded_width, coded_height, }) @@ -389,6 +401,20 @@ mod tests { assert_eq!(params[1], pps); } + #[test] + fn avcc_parse_separates_sps_and_pps() { + let sps = Bytes::from_static(&[0x67, 0x42, 0xc0, 0x1f, 0xde]); + let pps0 = Bytes::from_static(&[0x68, 0xce, 0x3c, 0x80]); + let pps1 = Bytes::from_static(&[0x68, 0xce, 0x3c, 0x81]); + + let avcc = build_avcc(std::slice::from_ref(&sps), &[pps0.clone(), pps1.clone()]).unwrap(); + let parsed = Avcc::parse(&avcc).unwrap(); + + assert_eq!(parsed.length_size, 4); + assert_eq!(parsed.sps, vec![sps]); + assert_eq!(parsed.pps, vec![pps0, pps1]); + } + #[test] fn build_avcc_carries_multiple_pps() { // A source with one SPS and two PPS (ids 0 and 1): the avcC must keep both, diff --git a/rs/moq-mux/src/codec/h265/mod.rs b/rs/moq-mux/src/codec/h265/mod.rs index 5afebf188..c3f471ae8 100644 --- a/rs/moq-mux/src/codec/h265/mod.rs +++ b/rs/moq-mux/src/codec/h265/mod.rs @@ -312,6 +312,60 @@ pub(crate) fn hvcc_params(hvcc: &[u8]) -> anyhow::Result<(usize, Vec)> { Ok((length_size, params)) } +/// The parameter sets carried out-of-band in an HEVCDecoderConfigurationRecord, +/// split by NAL type. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Hvcc { + /// NALU length size in bytes (typically 4). + pub length_size: usize, + pub vps: Vec, + pub sps: Vec, + pub pps: Vec, +} + +impl Hvcc { + /// Parse an HEVCDecoderConfigurationRecord, sorting the VPS/SPS/PPS NAL units + /// by type. The HEVC analogue of [`super::h264::Avcc::parse`]: used to recover + /// the Annex-B parameter sets a length-prefixed (hvc1) stream needs at each + /// keyframe for in-band injection (VPS→SPS→PPS order). + pub fn parse(hvcc: &[u8]) -> anyhow::Result { + anyhow::ensure!(hvcc.len() >= 23, "HEVCDecoderConfigurationRecord too short"); + let length_size = (hvcc[21] & 0x03) as usize + 1; + let num_arrays = hvcc[22]; + + let mut out = Self { + length_size, + vps: Vec::new(), + sps: Vec::new(), + pps: Vec::new(), + }; + let mut pos = 23; + for _ in 0..num_arrays { + anyhow::ensure!(hvcc.len() >= pos + 3, "truncated hvcC NAL array header"); + let nal_type = hvcc[pos] & 0x3f; + let num_nalus = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]); + pos += 3; + for _ in 0..num_nalus { + anyhow::ensure!(hvcc.len() >= pos + 2, "truncated hvcC NAL length"); + let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; + pos += 2; + anyhow::ensure!(hvcc.len() >= pos + len, "hvcC NAL exceeds buffer"); + let nal = Bytes::copy_from_slice(&hvcc[pos..pos + len]); + pos += len; + match NALUnitType::from(nal_type) { + NALUnitType::VpsNut => out.vps.push(nal), + NALUnitType::SpsNut => out.sps.push(nal), + NALUnitType::PpsNut => out.pps.push(nal), + _ => {} + } + } + } + + Ok(out) + } +} + /// Pack the constraint flags from ITU H.265 V10 §7.3.3 Profile, tier and level syntax. pub(crate) fn pack_constraint_flags(profile: &scuffle_h265::Profile) -> [u8; 6] { let mut flags = [0u8; 6]; @@ -356,5 +410,12 @@ mod tests { assert_eq!(params[0].as_ref(), vps); assert_eq!(params[1].as_ref(), sps); assert_eq!(params[2].as_ref(), pps); + + // The typed parser keys the same NALs by type. + let parsed = Hvcc::parse(&hvcc).unwrap(); + assert_eq!(parsed.length_size, 4); + assert_eq!(parsed.vps, vec![Bytes::copy_from_slice(vps)]); + assert_eq!(parsed.sps, vec![Bytes::copy_from_slice(sps)]); + assert_eq!(parsed.pps, vec![Bytes::copy_from_slice(pps)]); } } diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml new file mode 100644 index 000000000..d35fb619c --- /dev/null +++ b/rs/moq-rtc/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "moq-rtc" +description = "WebRTC (WHIP/WHEP) gateway for Media over QUIC" +authors = ["Luke Curley "] +repository = "https://github.com/moq-dev/moq" +license = "MIT OR Apache-2.0" + +version = "0.0.1" +edition = "2024" +rust-version.workspace = true + +keywords = ["webrtc", "whip", "whep", "moq", "media"] +categories = ["multimedia", "network-programming", "web-programming"] + +[lib] +doctest = false + +[[bin]] +name = "moq-rtc" +path = "bin/moq-rtc.rs" +doc = false +# The binary (TLS listener, CLI, relay client) needs the `server` feature; the +# library can be depended on with `default-features = false` to embed the +# WHIP/WHEP gateway (the axum routers and WHEP/WHIP client) into another process. +required-features = ["server"] + +[features] +default = ["server", "iroh", "noq", "websocket"] +# Standalone binary: TLS-terminating listener, CORS, CLI parsing, and the +# moq-native relay client. Pulls in axum-server / clap / moq-native / rustls / +# sd-notify / tower-http. +server = ["dep:axum-server", "dep:clap", "dep:moq-native", "dep:rustls", "dep:sd-notify", "dep:tower-http"] +iroh = ["server", "moq-native/iroh"] +noq = ["server", "moq-native/noq"] +quinn = ["server", "moq-native/quinn"] +quiche = ["server", "moq-native/quiche"] +websocket = ["server", "moq-native/websocket"] + +# The embeddable gateway library needs only anyhow/axum/bytes/hang/moq-mux/ +# moq-net/reqwest/str0m/thiserror/tokio/tracing/url/uuid. The rest +# (axum-server/clap/moq-native/rustls/sd-notify/tower-http) are `optional` and +# pulled in by the `server` feature for the binary. +[dependencies] +anyhow = { version = "1", features = ["backtrace"] } +axum = { version = "0.8", features = ["tokio"] } +axum-server = { version = "0.8", features = ["tls-rustls"], optional = true } +bytes = "1" +clap = { version = "4", features = ["derive"], optional = true } +hang = { workspace = true } +moq-mux = { workspace = true } +moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"], optional = true } +moq-net = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false, optional = true } +str0m = "0.19" +thiserror = "2" +tokio = { workspace = true, features = ["full"] } +tower-http = { version = "0.6", features = ["cors"], optional = true } +tracing = "0.1" +url = "2" +uuid = { version = "1", features = ["v4"] } + +[target.'cfg(unix)'.dependencies] +sd-notify = { version = "0.5", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/rs/moq-rtc/bin/moq-rtc.rs b/rs/moq-rtc/bin/moq-rtc.rs new file mode 100644 index 000000000..88a01ec59 --- /dev/null +++ b/rs/moq-rtc/bin/moq-rtc.rs @@ -0,0 +1,263 @@ +//! `moq-rtc` binary. +//! +//! Subcommand structure mirrors `moq-cli`: globals first +//! (`--relay`, `--broadcast`), then HTTP role (`client` / `server`), then +//! direction (`publish` / `subscribe`). The 2x2 is: +//! +//! - `server publish` -- WHIP server, ingest from a remote publisher into MoQ. +//! - `server subscribe` -- WHEP server, egress a MoQ broadcast to remote subscribers. +//! - `client subscribe` -- WHEP client, pull a remote WHEP feed into MoQ. +//! - `client publish` -- WHIP client, push a MoQ broadcast to a remote WHIP endpoint. + +use std::net::SocketAddr; +use std::path::PathBuf; + +use anyhow::Context; +use axum::Router; +use clap::{Parser, Subcommand}; +use moq_rtc::{Client, Server}; +use tower_http::cors::{Any, CorsLayer}; +use url::Url; + +#[derive(Parser, Clone)] +#[command(version)] +struct Cli { + #[command(flatten)] + log: moq_native::Log, + + /// MoQ client configuration for dialing the upstream relay. + #[command(flatten)] + moq_client: moq_native::ClientConfig, + + /// URL of the upstream MoQ relay where ingested broadcasts get + /// published and from which egressed broadcasts are pulled. + #[arg(long, env = "MOQ_RTC_RELAY")] + relay: Url, + + /// Broadcast name on the MoQ relay this gateway binds to. For + /// `server publish` this is the path that arrives at the WHIP + /// endpoint; for the other three it's a single name pinned to the + /// gateway. + #[arg(long, alias = "name", env = "MOQ_RTC_BROADCAST")] + broadcast: String, + + /// Public UDP socket address(es) to advertise as ICE host candidates. + /// Repeat the flag (or comma-separate) to advertise both an IPv4 and + /// an IPv6 candidate on a dual-stack deployment. When empty, the + /// session relies on str0m discovering peer-reflexive candidates via + /// STUN binding requests, which is enough for most NAT scenarios. + #[arg(long, env = "MOQ_RTC_PUBLIC_ADDR", value_delimiter = ',')] + public_addr: Vec, + + #[command(subcommand)] + role: Role, +} + +#[derive(Subcommand, Clone)] +enum Role { + /// Dial out: act as an HTTP client and POST an SDP offer to a remote + /// WHIP or WHEP URL. + Client { + /// Remote WHIP or WHEP resource URL. + #[arg(long, env = "MOQ_RTC_URL")] + url: Url, + + #[command(subcommand)] + direction: Direction, + }, + /// Listen: accept incoming WHIP or WHEP HTTP requests. + Server { + /// HTTP listener for the WHIP/WHEP endpoints. + #[arg(long, env = "MOQ_RTC_LISTEN", default_value = "[::]:8088")] + listen: SocketAddr, + + /// UDP socket the shared WebRTC media mux binds to. All WHIP/WHEP + /// sessions share this one port (demuxed by ICE ufrag), so open just + /// this in the firewall. `0.0.0.0:0` lets the OS pick (loopback/dev). + #[arg(long, env = "MOQ_RTC_UDP_BIND", default_value = "0.0.0.0:0")] + udp_bind: SocketAddr, + + /// Optional TLS cert (PEM). Requires `--tls-key`. + #[arg(long, env = "MOQ_RTC_TLS_CERT", requires = "tls_key")] + tls_cert: Option, + + #[arg(long, env = "MOQ_RTC_TLS_KEY", requires = "tls_cert")] + tls_key: Option, + + #[command(subcommand)] + direction: Direction, + }, +} + +#[derive(Subcommand, Clone, Copy, Debug)] +enum Direction { + /// WHIP (publish protocol): RTP flows toward the WHIP-server endpoint. + Publish, + /// WHEP (subscribe protocol): RTP flows away from the WHEP-server endpoint. + Subscribe, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("failed to install default crypto provider"); + + let Cli { + log, + moq_client, + relay, + broadcast, + public_addr, + role, + } = Cli::parse(); + log.init()?; + + let moq_client = moq_client.init().context("failed to init moq client")?; + + // Two origins so ingest and egress see independent views of the + // upstream relay. The publisher feeds the relay; the subscriber + // reads from it. + let publisher = moq_net::Origin::random().produce(); + let subscriber = moq_net::Origin::random().produce(); + let subscriber_consumer = subscriber.consume(); + + let reconnect = moq_client + .with_publish(publisher.consume()) + .with_consume(subscriber) + .reconnect(relay.clone()); + + tracing::info!(%relay, %broadcast, "starting moq-rtc"); + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + let driver = run_role(role, &broadcast, public_addr, publisher, subscriber_consumer); + + tokio::select! { + res = driver => res, + res = reconnect.closed() => res.map_err(Into::into), + _ = tokio::signal::ctrl_c() => Ok(()), + } +} + +async fn run_role( + role: Role, + broadcast: &str, + public_addr: Vec, + publisher: moq_net::OriginProducer, + subscriber: moq_net::OriginConsumer, +) -> anyhow::Result<()> { + match role { + Role::Server { + listen, + udp_bind, + tls_cert, + tls_key, + direction, + } => { + run_server( + public_addr, + udp_bind, + publisher, + subscriber, + listen, + tls_cert, + tls_key, + direction, + ) + .await + } + Role::Client { url, direction } => { + run_client(broadcast, public_addr, publisher, subscriber, url, direction).await + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_server( + public_addr: Vec, + udp_bind: SocketAddr, + publisher: moq_net::OriginProducer, + subscriber: moq_net::OriginConsumer, + listen: SocketAddr, + tls_cert: Option, + tls_key: Option, + direction: Direction, +) -> anyhow::Result<()> { + let mut config = moq_rtc::server::Config::default(); + config.ice_candidates = public_addr; + config.udp_bind = udp_bind; + let server = Server::new(config, publisher, subscriber); + + let app = match direction { + Direction::Publish => Router::new().merge(server.publish_router()), + Direction::Subscribe => Router::new().merge(server.subscribe_router()), + }; + let app = app.layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any)); + + tracing::info!(%listen, ?direction, "moq-rtc server listening"); + serve(app, listen, tls_cert, tls_key).await +} + +async fn run_client( + broadcast_name: &str, + public_addr: Vec, + publisher: moq_net::OriginProducer, + subscriber: moq_net::OriginConsumer, + url: Url, + direction: Direction, +) -> anyhow::Result<()> { + let mut config = moq_rtc::client::Config::default(); + config.ice_candidates = public_addr; + let client = Client::new(config); + + match direction { + Direction::Subscribe => { + // WHEP client: receive remote RTP, publish it as `broadcast_name`. + // The announcement lives as long as `broadcast` (moved into the client + // subscribe task below) and is withdrawn when that producer closes. + let broadcast = moq_net::Broadcast::new().produce(); + let consumer = broadcast.consume(); + if !publisher.publish_broadcast(broadcast_name, consumer) { + anyhow::bail!("failed to publish broadcast {broadcast_name}"); + } + client.subscribe(url, broadcast).await?; + } + Direction::Publish => { + // WHIP client: read the local broadcast, push as RTP to remote. + // Once the per-codec re-packetizer lands, this should poll + // `subscriber.announced()` to await the broadcast rather than + // erroring on first-miss. + let broadcast = subscriber + .request_broadcast(broadcast_name) + .await + .map_err(|_| anyhow::anyhow!("broadcast {} not announced", broadcast_name))?; + client.publish(url, broadcast).await?; + } + } + + // `client.subscribe` spawns the session in the background; block on + // ctrl-c instead of returning so the binary stays up. + tokio::signal::ctrl_c().await?; + Ok(()) +} + +async fn serve(app: Router, bind: SocketAddr, cert: Option, key: Option) -> anyhow::Result<()> { + let service = app.into_make_service(); + match (cert, key) { + (Some(cert), Some(key)) => { + let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert, key) + .await + .context("failed to load TLS cert/key")?; + axum_server::bind_rustls(bind, config).serve(service).await?; + } + (None, None) => { + axum_server::bind(bind).serve(service).await?; + } + // clap's `requires` already gates this at parse time; the explicit + // arm is belt-and-suspenders in case someone strips the attribute. + (Some(_), None) | (None, Some(_)) => anyhow::bail!("--tls-cert and --tls-key must be set together"), + } + Ok(()) +} diff --git a/rs/moq-rtc/src/client/mod.rs b/rs/moq-rtc/src/client/mod.rs new file mode 100644 index 000000000..e36a43042 --- /dev/null +++ b/rs/moq-rtc/src/client/mod.rs @@ -0,0 +1,63 @@ +//! HTTP-client side: dial a remote WHIP/WHEP endpoint over an SDP exchange. +//! +//! Counterpart to [`crate::server`]. Whereas the server accepts POSTed +//! offers, the client mints the offer with `str0m::Rtc::sdp_api` and POSTs +//! it to the remote URL. Once the answer arrives the same +//! [`crate::session::Session`] driver takes over, so the per-codec bridges +//! and UDP socket loop are shared. + +pub mod whep; +pub mod whip; + +use std::net::SocketAddr; + +use url::Url; + +/// Configuration shared by both `client publish` and `client subscribe`. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct Config { + /// Public UDP socket addresses to advertise as ICE host candidates in + /// our outbound offer. Same semantics as [`crate::server::Config`]. + pub ice_candidates: Vec, +} + +/// Outbound WHIP/WHEP dialer. +/// +/// Owns a [`reqwest::Client`] reused across calls so connection pooling and +/// rustls config survive between resources. +#[derive(Clone)] +pub struct Client { + config: Config, + http: reqwest::Client, +} + +impl Client { + pub fn new(config: Config) -> Self { + Self { + config, + http: reqwest::Client::new(), + } + } + + pub(crate) fn config(&self) -> &Config { + &self.config + } + + pub(crate) fn http(&self) -> &reqwest::Client { + &self.http + } + + /// `client subscribe`: pull a remote WHEP feed and publish it as + /// `broadcast` on the local origin. Returns once the session is + /// running in the background. + pub async fn subscribe(&self, url: Url, broadcast: moq_net::BroadcastProducer) -> crate::Result<()> { + whep::dial(self, url, broadcast).await + } + + /// `client publish`: pull a local broadcast and push it to a remote + /// WHIP endpoint. Gated on the per-codec re-packetizer. + pub async fn publish(&self, url: Url, broadcast: moq_net::BroadcastConsumer) -> crate::Result<()> { + whip::dial(self, url, broadcast).await + } +} diff --git a/rs/moq-rtc/src/client/whep.rs b/rs/moq-rtc/src/client/whep.rs new file mode 100644 index 000000000..1d815575c --- /dev/null +++ b/rs/moq-rtc/src/client/whep.rs @@ -0,0 +1,73 @@ +//! `client subscribe`: dial a remote WHEP endpoint, ingest RTP into a +//! local moq-net broadcast. +//! +//! Mints an SDP offer with `recvonly` audio and video, POSTs it to the +//! WHEP resource URL with `Content-Type: application/sdp`, parses the +//! returned answer, then hands the resulting `str0m::Rtc` to a +//! [`crate::session::Session`] that reuses [`IngestSink`] (the same sink the +//! WHIP server uses). + +use std::time::Instant; + +use str0m::{ + Candidate, Rtc, + change::SdpAnswer, + media::{Direction, MediaKind}, +}; +use url::Url; + +use crate::{Error, Result, client::Client, ingest::IngestSink, session}; + +pub(crate) async fn dial(client: &Client, url: Url, broadcast: moq_net::BroadcastProducer) -> Result<()> { + let sink = Box::new(IngestSink::new(broadcast)?); + + let (socket, candidates) = session::bind_udp(&client.config().ice_candidates).await?; + let mut rtc = Rtc::new(Instant::now()); + for addr in &candidates { + let cand = Candidate::host(*addr, "udp").map_err(str0m::RtcError::from)?; + rtc.add_local_candidate(cand); + } + + // Ask for both audio and video, recvonly. The remote answer can decline + // either by signaling inactive on that m-line. + let mut api = rtc.sdp_api(); + api.add_media(MediaKind::Audio, Direction::RecvOnly, None, None, None); + api.add_media(MediaKind::Video, Direction::RecvOnly, None, None, None); + let (offer, pending) = api + .apply() + .ok_or_else(|| Error::Other(anyhow::anyhow!("no SDP changes to apply")))?; + + let res = client + .http() + .post(url.clone()) + .header(reqwest::header::CONTENT_TYPE, "application/sdp") + .header(reqwest::header::ACCEPT, "application/sdp") + .body(offer.to_sdp_string()) + .send() + .await + .map_err(|err| Error::Other(anyhow::anyhow!("WHEP POST failed: {err}")))?; + + if !res.status().is_success() { + return Err(Error::Other(anyhow::anyhow!("WHEP server returned {}", res.status()))); + } + + let body = res + .text() + .await + .map_err(|err| Error::Other(anyhow::anyhow!("reading WHEP answer body: {err}")))?; + let answer = SdpAnswer::from_sdp_string(&body).map_err(|err| Error::InvalidSdp(err.to_string()))?; + + rtc.sdp_api().accept_answer(pending, answer).map_err(Error::Rtc)?; + tracing::info!(%url, "whep client connected"); + + // 1:1 socket (no demux on the client): pump its datagrams into the session. + // The session tags each datagram with the advertised candidate matching its + // family (str0m matches the destination against a host candidate, not the bind). + let inbound = session::spawn_socket_reader(socket.clone()); + let session = session::Session::ingest(rtc, socket, candidates, inbound, sink); + tokio::spawn(async move { + session::log_session_end("whep client", session.run().await); + }); + + Ok(()) +} diff --git a/rs/moq-rtc/src/client/whip.rs b/rs/moq-rtc/src/client/whip.rs new file mode 100644 index 000000000..0939528bd --- /dev/null +++ b/rs/moq-rtc/src/client/whip.rs @@ -0,0 +1,79 @@ +//! `client publish`: dial a remote WHIP endpoint and push a MoQ broadcast +//! out as RTP. +//! +//! Mints an SDP offer with `sendonly` audio + video, POSTs it to the WHIP +//! resource URL, parses the returned answer, and hands the resulting +//! `str0m::Rtc` to a `crate::session::Session` running in egress mode. The +//! bitstream / RTP packetization is identical to the WHEP server path, so +//! most of the work lives in [`crate::egress`]. + +use str0m::{ + Candidate, + change::SdpAnswer, + media::{Direction, MediaKind}, +}; +use url::Url; + +use crate::{Error, Result, client::Client, egress::EgressSource, session}; + +pub(crate) async fn dial(client: &Client, url: Url, broadcast: moq_net::BroadcastConsumer) -> Result<()> { + let source = EgressSource::new(broadcast).await?; + let codecs = source.catalog_codecs(); + if codecs.is_empty() { + return Err(Error::Other(anyhow::anyhow!( + "catalog has no codecs we can egress (Opus / H.264 / H.265 / VP8 / VP9 / AV1)" + ))); + } + + let (socket, candidates) = session::bind_udp(&client.config().ice_candidates).await?; + // Restrict to codecs the catalog can actually source so the remote + // answer doesn't pick a codec we have no rendition for. + let mut rtc = session::rtc_with_codecs(&codecs); + for addr in &candidates { + let cand = Candidate::host(*addr, "udp").map_err(str0m::RtcError::from)?; + rtc.add_local_candidate(cand); + } + + // Advertise sendonly audio + video; `rtc_with_codecs` already pinned + // the offer's codec list to what the catalog can source. + let mut api = rtc.sdp_api(); + api.add_media(MediaKind::Audio, Direction::SendOnly, None, None, None); + api.add_media(MediaKind::Video, Direction::SendOnly, None, None, None); + let (offer, pending) = api + .apply() + .ok_or_else(|| Error::Other(anyhow::anyhow!("no SDP changes to apply")))?; + + let res = client + .http() + .post(url.clone()) + .header(reqwest::header::CONTENT_TYPE, "application/sdp") + .header(reqwest::header::ACCEPT, "application/sdp") + .body(offer.to_sdp_string()) + .send() + .await + .map_err(|err| Error::Other(anyhow::anyhow!("WHIP POST failed: {err}")))?; + + if !res.status().is_success() { + return Err(Error::Other(anyhow::anyhow!("WHIP server returned {}", res.status()))); + } + + let body = res + .text() + .await + .map_err(|err| Error::Other(anyhow::anyhow!("reading WHIP answer body: {err}")))?; + let answer = SdpAnswer::from_sdp_string(&body).map_err(|err| Error::InvalidSdp(err.to_string()))?; + + rtc.sdp_api().accept_answer(pending, answer).map_err(Error::Rtc)?; + tracing::info!(%url, "whip client connected"); + + // 1:1 socket (no demux on the client): pump its datagrams into the session. + // The session tags each datagram with the advertised candidate matching its + // family (str0m matches the destination against a host candidate, not the bind). + let inbound = session::spawn_socket_reader(socket.clone()); + let session = session::Session::egress(rtc, socket, candidates, inbound, source); + tokio::spawn(async move { + session::log_session_end("whip client", session.run().await); + }); + + Ok(()) +} diff --git a/rs/moq-rtc/src/codec/h264.rs b/rs/moq-rtc/src/codec/h264.rs new file mode 100644 index 000000000..fa31f081c --- /dev/null +++ b/rs/moq-rtc/src/codec/h264.rs @@ -0,0 +1,34 @@ +//! H.264 bridge. +//! +//! str0m hands us reassembled Annex-B frames (start-code prefixed NALs with +//! inline SPS/PPS), which is exactly what +//! [`moq_mux::codec::h264::Import`] in Avc3 mode wants. We just convert the +//! timestamp and stream NALs in. + +use crate::{Result, codec}; + +/// Feeds str0m's Annex-B H.264 access units into a moq-mux avc3 importer. +pub struct Bridge { + import: moq_mux::codec::h264::Import, +} + +impl Bridge { + /// Publish an `.avc3` track on `broadcast`, registering it in `catalog`. + pub fn new(broadcast: moq_net::BroadcastProducer, catalog: moq_mux::catalog::Producer) -> Result { + // Pin avc3 (Annex-B, inline SPS/PPS) up front: str0m always hands us that wire shape. + let import = + moq_mux::codec::h264::Import::new(broadcast, catalog).with_mode(moq_mux::codec::h264::Mode::Avc3)?; + Ok(Self { import }) + } +} + +impl codec::Bridge for Bridge { + fn push(&mut self, frame: codec::Frame) -> Result<()> { + let pts = moq_mux::container::Timestamp::from_micros(frame.timestamp_us) + .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; + // str0m hands over one whole access unit per frame. + let mut payload = frame.payload; + self.import.decode_frame(&mut payload, Some(pts))?; + Ok(()) + } +} diff --git a/rs/moq-rtc/src/codec/mod.rs b/rs/moq-rtc/src/codec/mod.rs new file mode 100644 index 000000000..aba003d7c --- /dev/null +++ b/rs/moq-rtc/src/codec/mod.rs @@ -0,0 +1,333 @@ +//! Per-codec bridges between moq-mux and str0m. +//! +//! Two directions: +//! - **Ingest** ([`Bridge`]): str0m hands a decoded codec frame via +//! `Event::MediaData`; the bridge converts it into the shape the +//! moq-mux importer expects and publishes it. +//! - **Egress** ([`Track`]): the egress source subscribes to a moq-mux +//! broadcast and the track yields RTP-ready codec frames that the +//! session loop hands to [`str0m::media::Writer::write`]. + +pub mod h264; +pub mod opus; +pub mod vp8; +pub mod vp9; + +use bytes::Bytes; +use hang::catalog::VideoConfig; +use str0m::format::Codec; + +use crate::Result; + +/// One codec frame received from str0m, paired with a microsecond timestamp. +/// +/// Used by the ingest path. The session loop converts str0m's +/// [`MediaTime`](str0m::media::MediaTime) to microseconds so individual +/// bridges don't need to repeat the math. +#[derive(Clone, Debug)] +pub struct Frame { + pub timestamp_us: u64, + pub payload: Bytes, +} + +/// Bridges depacketized media frames from str0m to a hang broadcast track. +/// +/// One bridge per `m=` line on the ingest side. The session loop calls +/// [`Bridge::push`] once per [`MediaData`](str0m::media::MediaData) event +/// with the codec frame; the bridge handles any codec-specific transformations +/// (e.g. Annex-B to AVCC for H.264) and forwards the frame into the matching +/// moq-mux importer. +pub trait Bridge: Send { + fn push(&mut self, frame: Frame) -> Result<()>; +} + +/// One RTP-ready codec frame produced by an egress [`Track`]. +/// +/// `timestamp_us` stays in microseconds; the session loop converts it to +/// the negotiated codec's clock domain when calling +/// [`Writer::write`](str0m::media::Writer::write). +#[derive(Clone, Debug)] +pub struct PacketizedFrame { + pub timestamp_us: u64, + pub payload: Bytes, +} + +/// A subscribed moq-mux track, normalized to the bitstream shape str0m's +/// Frame API expects. +/// +/// One [`Track`] per `m=` line on the egress side. The egress source spawns +/// a pump task per track that polls [`Track::next`] and forwards frames to +/// the session loop. +pub struct Track { + consumer: moq_mux::container::Consumer, + codec: Codec, + convert: TrackConvert, +} + +/// Codec-specific per-frame transform. +enum TrackConvert { + /// Opus / VP8 / VP9 / AV1, plus inline-parameter H.264 (avc3) and H.265 + /// (hev1): the stored bitstream is already in the shape str0m's + /// packetizer wants, so it passes through untouched. + Passthrough, + /// Out-of-band-parameter H.264 (avc1) and H.265 (hvc1): length-prefixed + /// NALU rewritten to Annex-B, with the cached parameter sets (SPS+PPS, + /// plus VPS for H.265) prepended to every keyframe. Both codecs share this + /// path; only the config record parsed to build it differs (avcC vs hvcC). + /// Mirrors moq-mux's `h264::Export` / `h265::Export`. + LengthPrefixed { length_size: usize, keyframe_prefix: Bytes }, +} + +impl Track { + /// Audio track for an Opus rendition. + pub async fn opus(broadcast: &moq_net::BroadcastConsumer, name: &str) -> Result { + let container = moq_mux::catalog::hang::Container::Legacy; + // The consumer starts at the latest (in-progress) group, which begins at a + // keyframe, so a late joiner gets a decodable start immediately rather than + // waiting for the next group boundary. + let track = broadcast.subscribe_track(&moq_net::Track::new(name))?; + let consumer = moq_mux::container::Consumer::new(track, container); + Ok(Self { + consumer, + codec: Codec::Opus, + convert: TrackConvert::Passthrough, + }) + } + + /// Video track. Codec inferred from `config.codec`; for H.264 / H.265 the + /// bitstream shape (inline vs out-of-band parameter sets) is inferred from + /// `config.description` (avc1/hvc1 vs avc3/hev1). + pub async fn video(broadcast: &moq_net::BroadcastConsumer, name: &str, config: &VideoConfig) -> Result { + let container: moq_mux::catalog::hang::Container = (&config.container).try_into()?; + // The consumer starts at the latest (in-progress) group, which begins at a + // keyframe, so a late-joining peer gets a decodable start. + let track = broadcast.subscribe_track(&moq_net::Track::new(name))?; + let consumer = moq_mux::container::Consumer::new(track, container); + + let (codec, convert) = match &config.codec { + hang::catalog::VideoCodec::VP8 => (Codec::Vp8, TrackConvert::Passthrough), + hang::catalog::VideoCodec::VP9(_) => (Codec::Vp9, TrackConvert::Passthrough), + hang::catalog::VideoCodec::AV1(_) => (Codec::Av1, TrackConvert::Passthrough), + hang::catalog::VideoCodec::H264(_) => (Codec::H264, h264_convert(config)?), + hang::catalog::VideoCodec::H265(_) => (Codec::H265, h265_convert(config)?), + other => return Err(crate::Error::UnsupportedCodec(format!("{other:?}"))), + }; + + Ok(Self { + consumer, + codec, + convert, + }) + } + + pub fn codec(&self) -> Codec { + self.codec + } + + /// Pull the next RTP-ready frame. Returns `None` when the track ends. + pub async fn next(&mut self) -> Result> { + loop { + let Some(frame) = self.consumer.read().await? else { + return Ok(None); + }; + let payload = match &self.convert { + TrackConvert::Passthrough => frame.payload, + TrackConvert::LengthPrefixed { + length_size, + keyframe_prefix, + } => { + let prefix = frame.keyframe.then(|| keyframe_prefix.as_ref()); + moq_mux::codec::annexb::from_length_prefixed(&frame.payload, *length_size, prefix) + .map_err(|err| crate::Error::Other(anyhow::anyhow!("annexb: {err}")))? + } + }; + if payload.is_empty() { + continue; + } + return Ok(Some(PacketizedFrame { + timestamp_us: frame.timestamp.as_micros() as u64, + payload, + })); + } + } +} + +/// Build the per-frame transform for an H.264 rendition. +/// +/// avc3 (inline SPS/PPS, empty `description`) passes through. avc1 (out-of-band +/// avcC in `description`) parses the avcC and prebuilds the Annex-B SPS+PPS +/// prefix to prepend ahead of every keyframe. +fn h264_convert(config: &VideoConfig) -> Result { + let Some(avcc) = config.description.as_ref().filter(|d| !d.is_empty()) else { + return Ok(TrackConvert::Passthrough); + }; + let params = moq_mux::codec::h264::Avcc::parse(avcc) + .map_err(|err| crate::Error::Other(anyhow::anyhow!("avcc parse: {err}")))?; + // Without SPS+PPS the keyframe prefix would be empty and every keyframe + // would reach the peer without inline parameter sets, i.e. undecodable. + // Fail loudly instead, matching moq-mux's `h264::Export`. + if params.sps.is_empty() || params.pps.is_empty() { + return Err(crate::Error::Other(anyhow::anyhow!( + "avc1 avcC is missing parameter sets (sps={}, pps={})", + params.sps.len(), + params.pps.len() + ))); + } + let keyframe_prefix = moq_mux::codec::annexb::build_prefix(params.sps.iter().chain(params.pps.iter())); + Ok(TrackConvert::LengthPrefixed { + length_size: params.length_size, + keyframe_prefix, + }) +} + +/// Build the per-frame transform for an H.265 rendition. +/// +/// The H.265 analogue of [`h264_convert`]: hev1 (inline VPS/SPS/PPS) passes +/// through; hvc1 (out-of-band hvcC) parses the hvcC and prebuilds the Annex-B +/// VPS+SPS+PPS prefix to prepend ahead of every keyframe. +fn h265_convert(config: &VideoConfig) -> Result { + let Some(hvcc) = config.description.as_ref().filter(|d| !d.is_empty()) else { + return Ok(TrackConvert::Passthrough); + }; + let params = moq_mux::codec::h265::Hvcc::parse(hvcc) + .map_err(|err| crate::Error::Other(anyhow::anyhow!("hvcc parse: {err}")))?; + // Same reasoning as `h264_convert`: a keyframe with no inline VPS/SPS/PPS + // is undecodable, so reject an hvcC that omits any of them. + if params.vps.is_empty() || params.sps.is_empty() || params.pps.is_empty() { + return Err(crate::Error::Other(anyhow::anyhow!( + "hvc1 hvcC is missing parameter sets (vps={}, sps={}, pps={})", + params.vps.len(), + params.sps.len(), + params.pps.len() + ))); + } + let keyframe_prefix = + moq_mux::codec::annexb::build_prefix(params.vps.iter().chain(params.sps.iter()).chain(params.pps.iter())); + Ok(TrackConvert::LengthPrefixed { + length_size: params.length_size, + keyframe_prefix, + }) +} + +#[cfg(test)] +mod tests { + use hang::catalog::{H264, H265, VideoConfig}; + + use super::*; + + fn config(codec: impl Into, description: Option) -> VideoConfig { + let mut config = VideoConfig::new(codec); + config.description = description; + config + } + + fn h264(inline: bool) -> H264 { + H264 { + inline, + profile: 0x42, + constraints: 0, + level: 0x1f, + } + } + + fn h265(in_band: bool) -> H265 { + H265 { + in_band, + profile_space: 0, + profile_idc: 1, + profile_compatibility_flags: [0; 4], + tier_flag: false, + level_idc: 0x5d, + constraint_flags: [0; 6], + } + } + + /// Minimal avcC carrying one SPS + one PPS (lengthSizeMinusOne = 3). + fn build_avcc(sps: &[u8], pps: &[u8]) -> Bytes { + let mut v = vec![1, sps[1], sps[2], sps[3], 0xff, 0xe1]; + v.extend_from_slice(&(sps.len() as u16).to_be_bytes()); + v.extend_from_slice(sps); + v.push(1); + v.extend_from_slice(&(pps.len() as u16).to_be_bytes()); + v.extend_from_slice(pps); + Bytes::from(v) + } + + /// Minimal hvcC carrying one VPS + SPS + PPS array (lengthSizeMinusOne = 3). + /// VPS/SPS/PPS NAL unit types are 32/33/34. + fn build_hvcc(vps: &[u8], sps: &[u8], pps: &[u8]) -> Bytes { + let mut v = vec![0u8; 21]; + v.push(0xff); // [21] lengthSizeMinusOne = 3 in the low 2 bits + v.push(3); // [22] numOfArrays + for (nal_type, nal) in [(32u8, vps), (33, sps), (34, pps)] { + v.push(nal_type); // array header: low 6 bits = NAL unit type + v.extend_from_slice(&1u16.to_be_bytes()); // numNalus + v.extend_from_slice(&(nal.len() as u16).to_be_bytes()); + v.extend_from_slice(nal); + } + Bytes::from(v) + } + + #[test] + fn h264_avc3_passthrough() { + let cfg = config(h264(true), None); + assert!(matches!(h264_convert(&cfg).unwrap(), TrackConvert::Passthrough)); + } + + #[test] + fn h264_avc1_length_prefixed() { + let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f, 0xde]; + let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let cfg = config(h264(false), Some(build_avcc(sps, pps))); + + let TrackConvert::LengthPrefixed { + length_size, + keyframe_prefix, + } = h264_convert(&cfg).unwrap() + else { + panic!("expected LengthPrefixed"); + }; + assert_eq!(length_size, 4); + assert!(keyframe_prefix.starts_with(&[0, 0, 0, 1]), "Annex-B start code"); + assert!(keyframe_prefix.windows(sps.len()).any(|w| w == sps), "SPS in prefix"); + assert!(keyframe_prefix.windows(pps.len()).any(|w| w == pps), "PPS in prefix"); + } + + #[test] + fn h265_hev1_passthrough() { + let cfg = config(h265(true), None); + assert!(matches!(h265_convert(&cfg).unwrap(), TrackConvert::Passthrough)); + } + + #[test] + fn h265_hvc1_length_prefixed() { + let vps: &[u8] = &[0x40, 0x01, 0x0c, 0x01]; + let sps: &[u8] = &[0x42, 0x01, 0x01, 0x01]; + let pps: &[u8] = &[0x44, 0x01, 0xc0, 0xf7]; + let cfg = config(h265(false), Some(build_hvcc(vps, sps, pps))); + + let TrackConvert::LengthPrefixed { + length_size, + keyframe_prefix, + } = h265_convert(&cfg).unwrap() + else { + panic!("expected LengthPrefixed"); + }; + assert_eq!(length_size, 4); + // Parameter sets are prefixed in VPS, SPS, PPS order. + let v = keyframe_prefix.windows(vps.len()).position(|w| w == vps).expect("VPS"); + let s = keyframe_prefix.windows(sps.len()).position(|w| w == sps).expect("SPS"); + let p = keyframe_prefix.windows(pps.len()).position(|w| w == pps).expect("PPS"); + assert!(v < s && s < p, "VPS < SPS < PPS order in prefix"); + } + + /// An avc1 avcC that parses but carries no SPS/PPS must be rejected rather + /// than silently producing keyframes without inline parameter sets. + #[test] + fn h264_avc1_missing_param_sets_errors() { + // 6-byte header (numSPS = 0 in the low 5 bits of byte 5) + a zero PPS count. + let avcc = Bytes::from(vec![1, 0x42, 0, 0x1f, 0xff, 0xe0, 0x00]); + let cfg = config(h264(false), Some(avcc)); + assert!(h264_convert(&cfg).is_err(), "missing SPS/PPS must error"); + } +} diff --git a/rs/moq-rtc/src/codec/opus.rs b/rs/moq-rtc/src/codec/opus.rs new file mode 100644 index 000000000..700950da0 --- /dev/null +++ b/rs/moq-rtc/src/codec/opus.rs @@ -0,0 +1,39 @@ +//! Opus bridge. +//! +//! str0m hands us one Opus packet per frame, which is exactly the +//! raw shape that [`moq_mux::codec::opus::Import`] consumes. + +use crate::{Result, codec}; + +/// Feeds str0m's Opus packets into a moq-mux Opus importer. +pub struct Bridge { + import: moq_mux::codec::opus::Import, +} + +impl Bridge { + /// Publish an `.opus` track on `broadcast` at the negotiated sample rate / channel count. + pub fn new( + mut broadcast: moq_net::BroadcastProducer, + catalog: moq_mux::catalog::Producer, + sample_rate: u32, + channel_count: u32, + ) -> Result { + let config = moq_mux::codec::opus::Config { + sample_rate, + channel_count, + }; + let track = broadcast.unique_track(".opus")?; + let import = moq_mux::codec::opus::Import::new_with_track(track, catalog, config)?; + Ok(Self { import }) + } +} + +impl codec::Bridge for Bridge { + fn push(&mut self, frame: codec::Frame) -> Result<()> { + let pts = moq_mux::container::Timestamp::from_micros(frame.timestamp_us) + .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; + let mut payload = frame.payload; + self.import.decode(&mut payload, Some(pts))?; + Ok(()) + } +} diff --git a/rs/moq-rtc/src/codec/vp8.rs b/rs/moq-rtc/src/codec/vp8.rs new file mode 100644 index 000000000..559f68659 --- /dev/null +++ b/rs/moq-rtc/src/codec/vp8.rs @@ -0,0 +1,65 @@ +//! VP8 bridge. +//! +//! VP8 carries no out-of-band config record. str0m hands us complete frames +//! and we forward them to a `.vp8` track with the matching catalog entry. +//! Keyframes are detected from the first byte (P-frame bit, RFC 6386 §9.1). + +use crate::{Result, codec}; + +/// Forwards str0m's VP8 frames to a `.vp8` track, detecting keyframes inline. +pub struct Bridge { + catalog: moq_mux::catalog::Producer, + track: moq_mux::container::Producer, + announced: bool, +} + +impl Bridge { + /// Publish a `.vp8` track on `broadcast`; the catalog rendition is added on the first frame. + pub fn new(mut broadcast: moq_net::BroadcastProducer, catalog: moq_mux::catalog::Producer) -> Result { + let track = broadcast.unique_track(".vp8")?; + let producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy); + Ok(Self { + catalog, + track: producer, + announced: false, + }) + } + + fn announce(&mut self) { + if self.announced { + return; + } + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.container = hang::catalog::Container::Legacy; + self.catalog + .lock() + .video + .renditions + .insert(self.track.track().name.clone(), config); + self.announced = true; + } +} + +impl codec::Bridge for Bridge { + fn push(&mut self, frame: codec::Frame) -> Result<()> { + self.announce(); + let pts = moq_mux::container::Timestamp::from_micros(frame.timestamp_us) + .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; + // VP8: first byte bit 0 == 0 means keyframe (RFC 6386 §9.1). + let keyframe = frame.payload.first().map(|b| b & 0x01 == 0).unwrap_or(false); + self.track + .write(moq_mux::container::Frame { + timestamp: pts, + payload: frame.payload, + keyframe, + }) + .map_err(|err| crate::Error::Other(anyhow::anyhow!("vp8 track write failed: {err}")))?; + Ok(()) + } +} + +impl Drop for Bridge { + fn drop(&mut self) { + self.catalog.lock().video.renditions.remove(&self.track.track().name); + } +} diff --git a/rs/moq-rtc/src/codec/vp9.rs b/rs/moq-rtc/src/codec/vp9.rs new file mode 100644 index 000000000..535f8c921 --- /dev/null +++ b/rs/moq-rtc/src/codec/vp9.rs @@ -0,0 +1,119 @@ +//! VP9 bridge. +//! +//! Keyframes are detected from the frame_type bit (RFC 8741 §3 / VP9 spec §6.2: +//! the second bit of the uncompressed header). + +use crate::{Result, codec}; + +/// Forwards str0m's VP9 frames to a `.vp9` track, detecting keyframes inline. +pub struct Bridge { + catalog: moq_mux::catalog::Producer, + track: moq_mux::container::Producer, + announced: bool, +} + +impl Bridge { + /// Publish a `.vp9` track on `broadcast`; the catalog rendition is added on the first frame. + pub fn new(mut broadcast: moq_net::BroadcastProducer, catalog: moq_mux::catalog::Producer) -> Result { + let track = broadcast.unique_track(".vp9")?; + let producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy); + Ok(Self { + catalog, + track: producer, + announced: false, + }) + } + + fn announce(&mut self) { + if self.announced { + return; + } + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VP9::default()); + config.container = hang::catalog::Container::Legacy; + self.catalog + .lock() + .video + .renditions + .insert(self.track.track().name.clone(), config); + self.announced = true; + } +} + +impl codec::Bridge for Bridge { + fn push(&mut self, frame: codec::Frame) -> Result<()> { + self.announce(); + let pts = moq_mux::container::Timestamp::from_micros(frame.timestamp_us) + .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; + let keyframe = is_keyframe(&frame.payload); + self.track + .write(moq_mux::container::Frame { + timestamp: pts, + payload: frame.payload, + keyframe, + }) + .map_err(|err| crate::Error::Other(anyhow::anyhow!("vp9 track write failed: {err}")))?; + Ok(()) + } +} + +/// Detect a VP9 keyframe from the uncompressed header's first byte (VP9 spec +/// §6.2), reading bits MSB-first: `frame_marker(2)`, `profile_low(1)`, +/// `profile_high(1)`, a `reserved(1)` bit only when profile == 3, +/// `show_existing_frame(1)`, then `frame_type(1)` (0 == KEY_FRAME). A +/// show-existing frame carries no frame_type and is never a keyframe. +fn is_keyframe(payload: &[u8]) -> bool { + let Some(&b) = payload.first() else { + return false; + }; + let profile = (((b >> 4) & 1) << 1) | ((b >> 5) & 1); // (high << 1) | low + // Bits consumed from the MSB: 2 (marker) + 2 (profile), plus profile 3's reserved bit. + let mut pos = 4; + if profile == 3 { + pos += 1; + } + let show_existing_frame = (b >> (7 - pos)) & 1; + if show_existing_frame == 1 { + return false; + } + pos += 1; + let frame_type = (b >> (7 - pos)) & 1; + frame_type == 0 +} + +impl Drop for Bridge { + fn drop(&mut self) { + self.catalog.lock().video.renditions.remove(&self.track.track().name); + } +} + +#[cfg(test)] +mod tests { + use super::is_keyframe; + + // frame_marker = 0b10 in the top two bits for every well-formed header. + #[test] + fn profile0_keyframe_and_interframe() { + // profile 0, show_existing_frame = 0, frame_type = 0 (key) / 1 (inter). + assert!(is_keyframe(&[0b1000_0010])); + assert!(!is_keyframe(&[0b1000_0110])); + } + + #[test] + fn profile0_show_existing_frame_is_not_keyframe() { + // profile 0, show_existing_frame = 1: no frame_type follows. + assert!(!is_keyframe(&[0b1000_1000])); + } + + #[test] + fn profile3_keyframe_and_interframe() { + // profile 3 (both profile bits set) inserts a reserved bit before + // show_existing_frame, shifting frame_type one position right. + assert!(is_keyframe(&[0b1011_0000])); // reserved=0, show=0, frame_type=0 + assert!(!is_keyframe(&[0b1011_0010])); // frame_type=1 + } + + #[test] + fn empty_payload_is_not_keyframe() { + assert!(!is_keyframe(&[])); + } +} diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs new file mode 100644 index 000000000..da9b83eb4 --- /dev/null +++ b/rs/moq-rtc/src/egress.rs @@ -0,0 +1,233 @@ +//! Per-broadcast egress source for the RTP-out paths. +//! +//! Counterpart to [`crate::ingest::IngestSink`]. Holds a +//! [`moq_net::BroadcastConsumer`] and a cached catalog snapshot; on each +//! `MediaAdded` event the session loop calls [`EgressSource::on_track`] +//! which picks a matching rendition, subscribes to it, and spawns a pump +//! task that feeds RTP-ready frames back to the session loop via an mpsc +//! channel. +//! +//! Used by `server subscribe` (WHEP server) and `client publish` (WHIP +//! client). SDP negotiation lives in the matching modules; this file is +//! transport-agnostic. + +use std::time::Instant; + +use bytes::Bytes; +use hang::catalog::{AudioCodec, VideoCodec}; +use moq_mux::catalog::hang::Catalog; +use str0m::format::Codec; +use str0m::media::{Frequency, MediaTime, Mid, Pt}; +use tokio::sync::mpsc; + +use crate::{Error, Result, codec}; + +/// One frame waiting to be written into str0m's [`Writer`](str0m::media::Writer). +/// +/// Pump tasks build these and send them down the channel; the session loop +/// receives them and calls `rtc.writer(mid).write(pt, wallclock, time, payload)`. +pub struct WriteRequest { + pub mid: Mid, + pub pt: Pt, + pub time: MediaTime, + pub payload: Bytes, +} + +/// Holds the broadcast + catalog and spawns per-rendition pump tasks. +pub struct EgressSource { + broadcast: moq_net::BroadcastConsumer, + /// Snapshot of the catalog at session start. Sufficient for v1: SDP + /// negotiation happens once and the codec list is fixed for the + /// lifetime of the session. + catalog: Catalog, + writes_tx: mpsc::Sender, + writes_rx: Option>, +} + +impl EgressSource { + /// Subscribe to the broadcast's catalog and wait for the first snapshot. + /// + /// The session loop drives the pumps via the returned channel; the + /// caller hands `EgressSource` to [`Session::egress`](crate::session::Session::egress) + /// which takes the receiver via [`Self::take_writes`]. + pub async fn new(broadcast: moq_net::BroadcastConsumer) -> Result { + let catalog_track = broadcast.subscribe_track(&moq_net::Track::new(hang::Catalog::DEFAULT_NAME))?; + let mut consumer = moq_mux::catalog::hang::Consumer::new(catalog_track); + let catalog = consumer + .next() + .await + .map_err(|err| Error::Other(anyhow::anyhow!("catalog subscribe: {err}")))? + .ok_or_else(|| Error::Other(anyhow::anyhow!("catalog closed before first snapshot")))?; + + let (tx, rx) = mpsc::channel(64); + Ok(Self { + broadcast, + catalog, + writes_tx: tx, + writes_rx: Some(rx), + }) + } + + /// One-shot extractor for the write-request receiver. The session loop + /// awaits on this to forward frames into str0m. + pub fn take_writes(&mut self) -> mpsc::Receiver { + self.writes_rx.take().expect("EgressSource writes_rx already taken") + } + + /// Spawn a pump task for a newly added (sendonly) media line. + /// + /// `mid` and `pt` come from str0m's negotiated state; `clock_rate` is + /// the codec's negotiated RTP clock. The pump subscribes to a matching + /// catalog rendition and forwards every frame as a [`WriteRequest`]. + pub fn on_track(&mut self, mid: Mid, codec: Codec, pt: Pt, clock_rate: Frequency) -> Result<()> { + // the `subscribe` call blocks on SUBSCRIBE_OK, so pick + subscribe inside + // the pump task to keep this str0m callback non-blocking. + let tx = self.writes_tx.clone(); + let broadcast = self.broadcast.clone(); + let catalog = self.catalog.clone(); + tokio::spawn(async move { + let track = match pick_track(&broadcast, &catalog, codec).await { + Ok(Some(t)) => t, + Ok(None) => { + tracing::warn!(?codec, "no matching catalog rendition; egress track ignored"); + return; + } + Err(err) => { + tracing::warn!(?codec, %err, "egress track subscribe failed"); + return; + } + }; + pump(mid, pt, clock_rate, track, tx).await; + }); + Ok(()) + } + + /// Codecs present in the catalog, used by the SDP-offer side + /// (`client publish`) to declare what we have. For v1: the union of + /// audio + video codecs across all renditions. + pub fn catalog_codecs(&self) -> Vec { + let mut out = Vec::new(); + if self + .catalog + .audio + .renditions + .values() + .any(|r| matches!(r.codec, AudioCodec::Opus)) + { + out.push(Codec::Opus); + } + for rendition in self.catalog.video.renditions.values() { + if let Some(c) = video_codec(&rendition.codec) + && !out.contains(&c) + { + out.push(c); + } + } + out + } +} + +/// Map a hang catalog video codec to the str0m codec we can egress, if any. +fn video_codec(codec: &VideoCodec) -> Option { + match codec { + VideoCodec::H264(_) => Some(Codec::H264), + VideoCodec::H265(_) => Some(Codec::H265), + VideoCodec::VP8 => Some(Codec::Vp8), + VideoCodec::VP9(_) => Some(Codec::Vp9), + VideoCodec::AV1(_) => Some(Codec::Av1), + _ => None, + } +} + +/// Find the first catalog rendition for the given codec and build a +/// [`codec::Track`] subscribed to it. Returns `None` if no rendition matches. +async fn pick_track( + broadcast: &moq_net::BroadcastConsumer, + catalog: &Catalog, + codec: Codec, +) -> Result> { + match codec { + Codec::Opus => { + let Some((name, _config)) = catalog + .audio + .renditions + .iter() + .find(|(_, c)| matches!(c.codec, AudioCodec::Opus)) + else { + return Ok(None); + }; + Ok(Some(codec::Track::opus(broadcast, name).await?)) + } + Codec::H264 | Codec::H265 | Codec::Vp8 | Codec::Vp9 | Codec::Av1 => { + let Some((name, config)) = catalog + .video + .renditions + .iter() + .find(|(_, c)| video_codec(&c.codec) == Some(codec)) + else { + return Ok(None); + }; + Ok(Some(codec::Track::video(broadcast, name, config).await?)) + } + other => Err(Error::UnsupportedCodec(format!("{other:?}"))), + } +} + +/// Per-rendition pump task. Reads frames, converts the timestamp into the +/// codec's clock domain, and forwards as a [`WriteRequest`]. +async fn pump(mid: Mid, pt: Pt, clock_rate: Frequency, mut track: codec::Track, tx: mpsc::Sender) { + loop { + let frame = match track.next().await { + Ok(Some(f)) => f, + Ok(None) => { + tracing::debug!(?mid, "egress track ended"); + return; + } + Err(err) => { + tracing::warn!(?mid, %err, "egress track error"); + return; + } + }; + let ticks = us_to_ticks(frame.timestamp_us, clock_rate); + let time = MediaTime::new(ticks, clock_rate); + let req = WriteRequest { + mid, + pt, + time, + payload: frame.payload, + }; + if tx.send(req).await.is_err() { + // Session closed; drop the pump. + return; + } + } +} + +/// Convert a microsecond timestamp to a tick count at the given clock rate. +/// Uses u128 internally to avoid overflow at high tick rates. +fn us_to_ticks(timestamp_us: u64, clock_rate: Frequency) -> u64 { + let rate = clock_rate.get() as u128; + ((timestamp_us as u128 * rate) / 1_000_000) as u64 +} + +/// Write one `WriteRequest` into str0m. +/// +/// Lives here (not in session.rs) so the egress data shape is colocated +/// with the channel definition. Logs and swallows non-fatal errors; an +/// `UnknownPt` error after renegotiation isn't worth tearing down the +/// session over. +pub fn dispatch(rtc: &mut str0m::Rtc, request: WriteRequest, wallclock: Instant) { + let Some(writer) = rtc.writer(request.mid) else { + tracing::debug!(?request.mid, "egress write before media available"); + return; + }; + let WriteRequest { + pt, + time, + payload, + mid: _, + } = request; + if let Err(err) = writer.write(pt, wallclock, time, payload.to_vec()) { + tracing::warn!(%err, "egress write rejected by str0m"); + } +} diff --git a/rs/moq-rtc/src/error.rs b/rs/moq-rtc/src/error.rs new file mode 100644 index 000000000..fd8360dd7 --- /dev/null +++ b/rs/moq-rtc/src/error.rs @@ -0,0 +1,36 @@ +/// Errors produced by the WebRTC <-> MoQ gateway. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("invalid SDP: {0}")] + InvalidSdp(String), + + #[error("unsupported codec: {0}")] + UnsupportedCodec(String), + + #[error("session not found")] + SessionNotFound, + + #[error("session closed")] + SessionClosed, + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("moq error: {0}")] + Moq(#[from] moq_net::Error), + + #[error("mux error: {0}")] + Mux(#[from] moq_mux::Error), + + #[error("rtc error: {0}")] + Rtc(#[from] str0m::RtcError), + + #[error("rtc input error: {0}")] + RtcInput(#[from] str0m::error::NetError), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +pub type Result = std::result::Result; diff --git a/rs/moq-rtc/src/ingest.rs b/rs/moq-rtc/src/ingest.rs new file mode 100644 index 000000000..1f5c09667 --- /dev/null +++ b/rs/moq-rtc/src/ingest.rs @@ -0,0 +1,64 @@ +//! Per-broadcast [`MediaSink`](crate::session::MediaSink) used by every +//! RTP-in flow (`server publish` / WHIP server, `client subscribe` / WHEP +//! client). +//! +//! Holds the [`moq_net::BroadcastProducer`] and per-track codec bridges. +//! On `MediaAdded`, it inspects the negotiated codec and instantiates the +//! matching bridge; on each `MediaData`, it forwards into the bridge. + +use crate::{Error, Result, codec, session}; + +pub struct IngestSink { + broadcast: moq_net::BroadcastProducer, + catalog: moq_mux::catalog::Producer, + bridges: session::Bridges, +} + +impl IngestSink { + pub fn new(mut broadcast: moq_net::BroadcastProducer) -> Result { + let catalog = moq_mux::catalog::Producer::new(&mut broadcast)?; + Ok(Self { + broadcast, + catalog, + bridges: session::Bridges::new(), + }) + } +} + +impl session::MediaSink for IngestSink { + fn on_track( + &mut self, + mid: str0m::media::Mid, + _kind: str0m::media::MediaKind, + codec_kind: str0m::format::Codec, + audio_params: Option<(u32, u32)>, + ) -> Result<()> { + let bridge: Box = match codec_kind { + str0m::format::Codec::Opus => { + let (sample_rate, channels) = audio_params.unwrap_or((48_000, 2)); + Box::new(codec::opus::Bridge::new( + self.broadcast.clone(), + self.catalog.clone(), + sample_rate, + channels, + )?) + } + str0m::format::Codec::H264 => { + Box::new(codec::h264::Bridge::new(self.broadcast.clone(), self.catalog.clone())?) + } + str0m::format::Codec::Vp8 => { + Box::new(codec::vp8::Bridge::new(self.broadcast.clone(), self.catalog.clone())?) + } + str0m::format::Codec::Vp9 => { + Box::new(codec::vp9::Bridge::new(self.broadcast.clone(), self.catalog.clone())?) + } + other => return Err(Error::UnsupportedCodec(format!("{other:?}"))), + }; + self.bridges.insert(mid, bridge); + Ok(()) + } + + fn on_frame(&mut self, mid: str0m::media::Mid, frame: codec::Frame) -> Result<()> { + self.bridges.push(mid, frame) + } +} diff --git a/rs/moq-rtc/src/lib.rs b/rs/moq-rtc/src/lib.rs new file mode 100644 index 000000000..3baf7a967 --- /dev/null +++ b/rs/moq-rtc/src/lib.rs @@ -0,0 +1,53 @@ +//! WebRTC ↔ MoQ gateway. +//! +//! Bridges WHIP (RFC 9725) and WHEP between WebRTC peers and +//! [`moq_net`] broadcasts. The crate is split along two orthogonal axes +//! so all four combinations can land independently: +//! +//! | | RTP-in (ingest into MoQ) | RTP-out (egress from MoQ) | +//! |---|---|---| +//! | HTTP server | [`Server::publish_router`] (WHIP server) | [`Server::subscribe_router`] (WHEP server) | +//! | HTTP client | [`Client::subscribe`] (WHEP client) | [`Client::publish`] (WHIP client) | +//! +//! The two HTTP-client paths and the two HTTP-server paths share a single +//! [`session::Session`] driver and the same per-codec adapters in [`codec`]; +//! the per-direction split lives in [`session::MediaSink`] (ingest) / +//! [`egress::EgressSource`] (egress). +//! +//! ## Embedding +//! +//! Depend on this crate with `default-features = false` to embed the gateway +//! in another process: build a [`Server`] over your own +//! [`OriginProducer`](moq_net::OriginProducer) / +//! [`OriginConsumer`](moq_net::OriginConsumer) and merge +//! [`Server::publish_router`] / [`Server::subscribe_router`] into your own axum +//! app, or dial out with [`Client`]. That lean build skips the standalone +//! binary's deps (axum-server, clap, moq-native, rustls, sd-notify, tower-http), +//! which the `server` feature (on by default) pulls back in for the `moq-rtc` +//! binary. +//! +//! The bundled routers are unauthenticated: they derive the broadcast name from +//! the request path. To own the HTTP route and authorize requests yourself +//! (resolving the broadcast name from a verified token), skip the routers and +//! call [`whip::accept`] (ingest) / [`whep::accept`] (egress) from your own +//! handler. +//! +//! ## Bitstream gotcha +//! +//! The WebRTC ↔ MoQ shape conversion for H.264 is handled by `moq-mux`'s +//! `Avc3` importer: str0m hands us Annex-B (start-code NALs with inline +//! SPS/PPS) and that's exactly what the importer wants, so no extra +//! transform is needed in the gateway. Opus, VP8, and VP9 pass through. + +pub mod client; +pub mod codec; +pub mod egress; +mod error; +pub mod ingest; +pub mod sdp; +pub mod server; +pub mod session; + +pub use client::Client; +pub use error::*; +pub use server::{Response, Server, whep, whip}; diff --git a/rs/moq-rtc/src/sdp.rs b/rs/moq-rtc/src/sdp.rs new file mode 100644 index 000000000..b92dc9c3d --- /dev/null +++ b/rs/moq-rtc/src/sdp.rs @@ -0,0 +1,36 @@ +//! SDP plumbing. +//! +//! WHIP/WHEP both shovel SDP between a peer and str0m as `application/sdp` +//! request/response bodies. The only thing we add on top of str0m's offer/answer +//! parse/serialize is a tiny wrapper to keep the call sites readable. + +use std::str::FromStr; + +use crate::{Error, Result}; + +/// Parse an `application/sdp` body as an offer. +pub fn parse_offer(body: &str) -> Result { + str0m::change::SdpOffer::from_sdp_string(body).map_err(|err| Error::InvalidSdp(err.to_string())) +} + +/// Serialize an SDP answer for the `application/sdp` response body. +pub fn render_answer(answer: &str0m::change::SdpAnswer) -> String { + answer.to_sdp_string() +} + +/// Build a stable WHIP/WHEP resource identifier from a UUID v4. +pub fn new_resource_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Parse a `Location:`-style resource path into its trailing UUID component. +/// +/// WHIP DELETEs come back to `//`; this strips +/// everything but the id so the gateway can look up the session. +pub fn parse_resource_id(path: &str) -> Result { + let last = path + .rsplit('/') + .find(|s| !s.is_empty()) + .ok_or_else(|| Error::InvalidSdp("missing resource id".into()))?; + uuid::Uuid::from_str(last).map_err(|err| Error::InvalidSdp(err.to_string())) +} diff --git a/rs/moq-rtc/src/server/mod.rs b/rs/moq-rtc/src/server/mod.rs new file mode 100644 index 000000000..a82e9d904 --- /dev/null +++ b/rs/moq-rtc/src/server/mod.rs @@ -0,0 +1,214 @@ +//! HTTP-server side: accept WHIP/WHEP offers from remote clients. +//! +//! Mounts axum routers that publish into [`moq_net::OriginProducer`] (WHIP +//! / `server publish`) and pull from [`moq_net::OriginConsumer`] (WHEP / +//! `server subscribe`). The HTTP listener itself is the caller's +//! responsibility; the binary in `bin/moq-rtc.rs` mounts these under +//! axum_server. + +pub mod whep; +pub mod whip; + +mod mux; + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use tokio::sync::{OnceCell, oneshot}; + +use crate::Result; +use mux::Mux; + +/// The result of a WHIP/WHEP [`whip::accept`] / [`whep::accept`]: the SDP answer +/// to return to the client, plus an opaque resource id for the `Location` header +/// (the RFC 9725 session resource URL). +#[derive(Clone, Debug)] +pub struct Response { + /// Opaque id identifying the negotiated session, for the `Location` header. + pub resource_id: String, + /// The SDP answer body (`Content-Type: application/sdp`). + pub answer: String, +} + +/// Configuration shared by both `server publish` and `server subscribe`. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct Config { + /// Public UDP socket addresses that should be advertised as ICE host + /// candidates. Each is sent as a separate `candidate` line in the SDP + /// answer so a remote peer can reach us. + /// + /// If empty, the mux advertises whatever address the OS picked for the + /// shared socket. That works for loopback testing but not behind NAT. + pub ice_candidates: Vec, + + /// Address the shared WebRTC media socket binds to. Every WHIP/WHEP session + /// shares this one UDP port (demuxed by ICE ufrag), so a deployment opens + /// exactly one media port in its firewall. `0.0.0.0:0` (the default) lets + /// the OS pick a port, which is fine for dev/loopback; production pins it. + pub udp_bind: SocketAddr, +} + +impl Default for Config { + fn default() -> Self { + Self { + ice_candidates: Vec::new(), + udp_bind: SocketAddr::from(([0, 0, 0, 0], 0)), + } + } +} + +/// Glue that owns the moq-net origin pair and hands axum routers to the caller. +/// +/// `publisher` is where `server publish` (WHIP) writes ingested broadcasts; +/// `subscriber` is what `server subscribe` (WHEP) reads from. They're +/// typically the two halves of the same upstream [`moq_net::Session`]. +#[derive(Clone)] +pub struct Server { + inner: Arc, +} + +struct Inner { + config: Config, + publisher: moq_net::OriginProducer, + /// Source for `server subscribe` (WHEP) egress. + subscriber: moq_net::OriginConsumer, + /// The shared media socket + demux, bound lazily on the first accept so + /// `Server::new` can stay synchronous (and an idle server binds no port). + mux: OnceCell, + /// Live sessions keyed by resource id, each holding a cancel sender the + /// session task selects on. Lets [`Server::terminate`] (and the bundled + /// `DELETE` route) end a session by its `Location` id. + sessions: Mutex>>, +} + +impl Server { + /// Build a server. `publisher` receives WHIP broadcasts; `subscriber` + /// is the source for WHEP egress. + pub fn new(config: Config, publisher: moq_net::OriginProducer, subscriber: moq_net::OriginConsumer) -> Self { + Self { + inner: Arc::new(Inner { + config, + publisher, + subscriber, + mux: OnceCell::new(), + sessions: Mutex::new(HashMap::new()), + }), + } + } + + /// The shared media mux, bound (and its demux task spawned) on first use. + pub(crate) async fn mux(&self) -> Result<&Mux> { + self.inner + .mux + .get_or_try_init(|| Mux::bind(self.inner.config.udp_bind, &self.inner.config.ice_candidates)) + .await + } + + /// Router for `server publish` (WHIP). Mount under whichever HTTP path + /// the deployment prefers (`/whip`, `/`, ...). + /// + /// The router derives the broadcast name from the request path and performs + /// no authentication. To own the route and authorize requests yourself + /// (resolving the broadcast name from a verified token), skip the router and + /// call [`whip::accept`] directly from your own handler. + pub fn publish_router(&self) -> Router { + whip::router(self.clone()) + } + + /// Router for `server subscribe` (WHEP). Mount under whichever HTTP path + /// the deployment prefers (`/whep`, `/`, ...). + /// + /// The router derives the broadcast name from the request path and performs + /// no authentication. To own the route and authorize requests yourself + /// (resolving the broadcast name from a verified token), skip the router and + /// call [`whep::accept`] directly from your own handler. + pub fn subscribe_router(&self) -> Router { + whep::router(self.clone()) + } + + pub(crate) fn publisher(&self) -> &moq_net::OriginProducer { + &self.inner.publisher + } + + pub(crate) fn subscriber(&self) -> &moq_net::OriginConsumer { + &self.inner.subscriber + } + + /// Register a session under its resource id, returning the cancel receiver + /// the session task selects on. Called by [`whip::accept`] / [`whep::accept`] + /// before spawning the session. + pub(crate) fn register_session(&self, resource_id: String) -> oneshot::Receiver<()> { + let (tx, rx) = oneshot::channel(); + self.inner.sessions.lock().unwrap().insert(resource_id, tx); + rx + } + + /// Drop a session's registry entry once it has ended on its own. + pub(crate) fn unregister_session(&self, resource_id: &str) { + self.inner.sessions.lock().unwrap().remove(resource_id); + } + + /// Terminate a negotiated session by its resource id (the `Location` path + /// component from the WHIP/WHEP response). Returns `true` if a live session + /// was found and signalled to stop; the session task then releases its + /// broadcast announcement and mux registration. Embedders that own their own + /// HTTP routing call this to honor a WHIP/WHEP `DELETE`; the bundled routers + /// already wire it to the `DELETE` method. + pub fn terminate(&self, resource_id: &str) -> bool { + if let Some(cancel) = self.inner.sessions.lock().unwrap().remove(resource_id) { + let _ = cancel.send(()); + true + } else { + false + } + } +} + +/// Shared `DELETE` handler for both bundled routers: parse the resource id from +/// the trailing path segment and terminate the matching session. +pub(crate) async fn delete(State(server): State, Path(path): Path) -> StatusCode { + match crate::sdp::parse_resource_id(&path) { + Ok(id) if server.terminate(&id.to_string()) => StatusCode::OK, + Ok(_) => StatusCode::NOT_FOUND, + Err(_) => StatusCode::BAD_REQUEST, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server() -> Server { + let publisher = moq_net::Origin::random().produce(); + let subscriber = moq_net::Origin::random().produce().consume(); + Server::new(Config::default(), publisher, subscriber) + } + + #[test] + fn terminate_unknown_session_is_false() { + assert!(!server().terminate("00000000-0000-0000-0000-000000000000")); + } + + #[test] + fn terminate_registered_session_once() { + let server = server(); + let id = "11111111-1111-1111-1111-111111111111"; + let _cancel = server.register_session(id.to_string()); + assert!(server.terminate(id), "first terminate finds the session"); + assert!(!server.terminate(id), "second terminate is a no-op"); + } + + #[test] + fn unregister_drops_the_entry() { + let server = server(); + let id = "22222222-2222-2222-2222-222222222222"; + let _cancel = server.register_session(id.to_string()); + server.unregister_session(id); + assert!(!server.terminate(id), "unregistered session can't be terminated"); + } +} diff --git a/rs/moq-rtc/src/server/mux.rs b/rs/moq-rtc/src/server/mux.rs new file mode 100644 index 000000000..cae006e76 --- /dev/null +++ b/rs/moq-rtc/src/server/mux.rs @@ -0,0 +1,195 @@ +//! Single-UDP-port media mux for the WHIP/WHEP servers. +//! +//! str0m is sans-IO, so each [`Session`](crate::session::Session) needs UDP +//! datagrams fed to it. The naive approach (one ephemeral socket per session) +//! makes the media port unpredictable, so a deployment behind a firewall would +//! have to open the whole ephemeral range. Instead every server session shares +//! **one** UDP socket bound to a configured port; a demux task reads that socket +//! and routes each datagram to the right session. +//! +//! Routing key: ICE. A session is registered under the local ICE ufrag we mint +//! for it ([`IceCreds::new`]). The peer's first packets are STUN binding +//! requests whose USERNAME is `:`, so we parse the local +//! ufrag out of the STUN message and look the session up. Once we've seen a +//! source address we cache `addr -> session`, so subsequent DTLS/RTP/RTCP (which +//! carry no ufrag) route by address on the fast path. +//! +//! Backpressure mirrors a UDP socket buffer: each session has a bounded inbox +//! and a full inbox drops the datagram (WebRTC tolerates loss). A closed inbox +//! (session ended) evicts the address-cache entry; the ufrag entry is removed by +//! the [`Registration`] guard the accept path holds for the session's lifetime. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use str0m::ice::{IceCreds, StunMessage}; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; + +use crate::Result; +use crate::session::{Packet, SESSION_INBOX}; + +/// Per-session routing table, shared between the demux task and the accept path. +#[derive(Default)] +struct Registry { + /// Local ICE ufrag -> session inbox. Populated at registration, the only + /// way a brand-new peer (STUN binding request) finds its session. + by_ufrag: HashMap>, + /// Source address -> session inbox. Cached after first contact so non-STUN + /// packets (DTLS/RTP/RTCP, which carry no ufrag) route without parsing. + by_addr: HashMap>, +} + +/// The shared media socket plus its demux routing table. +/// +/// One per [`Server`](crate::server::Server), bound lazily on the first +/// WHIP/WHEP accept so `Server::new` can stay synchronous. +pub(crate) struct Mux { + socket: Arc, + registry: Arc>, + /// ICE host candidates to advertise: the configured public IP(s) (or the + /// bound address if none) paired with the shared socket's actual port. + candidates: Vec, +} + +/// Removes a session's ufrag entry (and sweeps any dead address entries) when +/// dropped. The accept path hands this to the session task, so the registration +/// lives exactly as long as the session. +pub(crate) struct Registration { + ufrag: String, + registry: Arc>, +} + +impl Drop for Registration { + fn drop(&mut self) { + let mut registry = self.registry.lock().unwrap(); + registry.by_ufrag.remove(&self.ufrag); + // Sweep address-cache entries whose session inbox has closed (this one, + // and any other session that ended without another inbound packet to + // trigger the per-packet eviction below). + registry.by_addr.retain(|_, tx| !tx.is_closed()); + } +} + +impl Mux { + /// Bind the shared socket to `udp_bind` and spawn the demux task. The + /// advertised candidates are `ice_candidates` (each reusing the socket's + /// real port), or the bound address itself when none are configured. + pub(crate) async fn bind(udp_bind: SocketAddr, ice_candidates: &[SocketAddr]) -> Result { + let socket = Arc::new(UdpSocket::bind(udp_bind).await?); + let port = socket.local_addr()?.port(); + let candidates = if ice_candidates.is_empty() { + vec![socket.local_addr()?] + } else { + // str0m's ICE agent sends to the candidate's port, so reuse the one + // real bound port across each advertised IP. + ice_candidates + .iter() + .map(|addr| SocketAddr::new(addr.ip(), port)) + .collect() + }; + + let registry = Arc::new(Mutex::new(Registry::default())); + tokio::spawn(demux(socket.clone(), registry.clone())); + + tracing::info!(?candidates, bound = %socket.local_addr()?, "webrtc media mux listening"); + Ok(Self { + socket, + registry, + candidates, + }) + } + + /// Mint ICE credentials for a new session and register its inbox. Returns + /// the credentials (set on the session's [`Rtc`](str0m::Rtc) so the demux's + /// ufrag lookup matches), the inbox receiver the session reads from, and a + /// [`Registration`] guard the session task must hold for its lifetime. + pub(crate) fn register(&self) -> (IceCreds, mpsc::Receiver, Registration) { + let creds = IceCreds::new(); + let (tx, rx) = mpsc::channel(SESSION_INBOX); + self.registry.lock().unwrap().by_ufrag.insert(creds.ufrag.clone(), tx); + let registration = Registration { + ufrag: creds.ufrag.clone(), + registry: self.registry.clone(), + }; + (creds, rx, registration) + } + + /// The shared socket, handed to each session for sending. + pub(crate) fn socket(&self) -> Arc { + self.socket.clone() + } + + /// ICE host candidates to advertise in the SDP answer. The session tags each + /// inbound datagram with the family-matching candidate; never empty (falls + /// back to the bound address). + pub(crate) fn candidates(&self) -> &[SocketAddr] { + &self.candidates + } +} + +/// Read the shared socket forever, routing each datagram to its session. +async fn demux(socket: Arc, registry: Arc>) { + let mut buf = vec![0u8; 65_535]; + loop { + let (len, src) = match socket.recv_from(&mut buf).await { + Ok(v) => v, + // recv errors on UDP are typically transient (e.g. an ICMP + // port-unreachable surfacing as ECONNREFUSED); keep serving. + Err(err) => { + tracing::warn!(%err, "webrtc media mux recv failed"); + continue; + } + }; + let data = &buf[..len]; + + // Fast path: a source we've already paired with a session. + let sender = registry.lock().unwrap().by_addr.get(&src).cloned(); + let sender = match sender { + Some(sender) => Some(sender), + // New source: only a STUN binding request (carrying the local + // ufrag) can introduce one. Parse outside the lock. + None => match local_ufrag(data) { + Some(ufrag) => { + let mut registry = registry.lock().unwrap(); + match registry.by_ufrag.get(&ufrag).cloned() { + // Cache addr -> session so this peer's later non-STUN + // packets route without re-parsing. + Some(sender) => { + registry.by_addr.insert(src, sender.clone()); + Some(sender) + } + None => None, + } + } + None => None, + }, + }; + + let Some(sender) = sender else { + // Unknown source and no matching ufrag: not our session, drop it. + continue; + }; + + // Bounded like a socket buffer: drop on full (WebRTC tolerates loss), + // evict on closed (the session ended). + match sender.try_send((data.to_vec(), src)) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => {} + Err(mpsc::error::TrySendError::Closed(_)) => { + registry.lock().unwrap().by_addr.remove(&src); + } + } + } +} + +/// Extract the local ICE ufrag from a STUN binding request, if `data` is one. +/// The USERNAME is `:`; we route on the local half. +fn local_ufrag(data: &[u8]) -> Option { + let msg = StunMessage::parse(data).ok()?; + if !msg.is_binding_request() { + return None; + } + msg.split_username().map(|(local, _remote)| local.to_string()) +} diff --git a/rs/moq-rtc/src/server/whep.rs b/rs/moq-rtc/src/server/whep.rs new file mode 100644 index 000000000..b81dfd4ca --- /dev/null +++ b/rs/moq-rtc/src/server/whep.rs @@ -0,0 +1,153 @@ +//! `server subscribe`: WHEP server. +//! +//! `POST /` accepts a WHEP SDP offer and returns an SDP +//! answer sourced from the matching MoQ broadcast on the subscribe origin. + +use axum::{ + Router, + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response as HttpResponse}, + routing::post, +}; +use str0m::Candidate; + +use crate::{Error, Result, egress::EgressSource, sdp, server::Server, session}; + +pub use crate::server::Response; + +/// Build the WHEP axum router. +pub fn router(server: Server) -> Router { + Router::new() + .route("/{*path}", post(handle).delete(crate::server::delete)) + .with_state(server) +} + +async fn handle(server: State, path: Path, headers: HeaderMap, body: Bytes) -> HttpResponse { + let (server, path) = (server.0, path.0); + match accept_offer(&server, &path, &headers, body).await { + Ok(Response { resource_id, answer }) => { + let mut response_headers = HeaderMap::new(); + response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/sdp")); + if let Ok(loc) = HeaderValue::from_str(&format!("/{path}/{resource_id}")) { + response_headers.insert(header::LOCATION, loc); + } + (StatusCode::CREATED, response_headers, answer).into_response() + } + Err(err) => { + tracing::warn!(%err, "whep request failed"); + (status_for(&err), err.to_string()).into_response() + } + } +} + +/// Router glue: enforce the WHEP `Content-Type` then hand the raw offer to +/// [`accept`], using the request path as the (unauthenticated) broadcast name. +async fn accept_offer(server: &Server, path: &str, headers: &HeaderMap, body: Bytes) -> Result { + if !is_sdp(headers) { + return Err(Error::InvalidSdp("expected Content-Type: application/sdp".into())); + } + let offer = std::str::from_utf8(&body).map_err(|err| Error::InvalidSdp(err.to_string()))?; + accept(server, server.subscriber(), path, offer).await +} + +/// Accept a WHEP SDP offer and egress the MoQ broadcast `broadcast` (a path +/// relative to `subscriber`'s root) to the negotiated WebRTC peer. +/// +/// This is the negotiation core behind [`router`], exposed so an embedder can own +/// the HTTP route and authentication: verify the request, resolve the authorized +/// broadcast name, scope `subscriber` to the caller's grants, then hand the raw +/// SDP offer here. Taking the consumer explicitly (rather than using the server's) +/// lets the embedder egress through a *scoped* origin, so the subscribe scope is +/// enforced by moq-net exactly as for a native session; the bundled [`router`] +/// passes the server's own (unauthenticated) consumer. It parses the offer, +/// resolves the broadcast on `subscriber`, restricts the answer to the codecs the +/// catalog actually has, registers a media session on the shared mux, spawns the +/// MoQ->RTP session, and returns the SDP answer plus an opaque `resource_id` for +/// the WHEP `Location` header. Mirrors [`whip::accept`](super::whip::accept). +/// +/// `offer` is the raw SDP body; the caller is responsible for checking the +/// `Content-Type: application/sdp` request header. Fails with [`Error::InvalidSdp`] +/// on a malformed offer, and surfaces a not-announced broadcast (or one outside +/// `subscriber`'s scope) as [`Error::Other`]. +pub async fn accept( + server: &Server, + subscriber: &moq_net::OriginConsumer, + broadcast: impl moq_net::AsPath, + offer: &str, +) -> Result { + let offer = sdp::parse_offer(offer)?; + + // Look up the MoQ broadcast on the subscribe origin. `request_broadcast` resolves an + // already-announced broadcast immediately and falls back to a dynamic handler if the + // origin has one; with neither, it resolves to an error and the WHEP client retries (typical). + let broadcast = broadcast.as_path().to_string(); + let consumer = subscriber + .request_broadcast(&broadcast) + .await + .map_err(|_| Error::Other(anyhow::anyhow!("broadcast {broadcast} not announced")))?; + + let source = EgressSource::new(consumer).await?; + let codecs = source.catalog_codecs(); + if codecs.is_empty() { + return Err(Error::Other(anyhow::anyhow!( + "catalog has no codecs we can egress (Opus / H.264 / H.265 / VP8 / VP9 / AV1)" + ))); + } + + // Register a session on the shared media mux (see whip::accept). Restrict our + // CodecConfig before accept_offer so the answer intersects the peer's offer + // with what the catalog actually has, instead of agreeing to a codec we can't + // fulfil; set the mux's known ICE credentials on the same config. + let mux = server.mux().await?; + let (creds, inbound, registration) = mux.register(); + let mut rtc = session::rtc_config_with_codecs(&codecs) + .set_local_ice_credentials(creds) + .build(std::time::Instant::now()); + for addr in mux.candidates() { + let cand = Candidate::host(*addr, "udp").map_err(str0m::RtcError::from)?; + rtc.add_local_candidate(cand); + } + + let answer = rtc.sdp_api().accept_offer(offer).map_err(Error::Rtc)?; + let resource_id = sdp::new_resource_id(); + let session = session::Session::egress(rtc, mux.socket(), mux.candidates().to_vec(), inbound, source); + + // Register before spawning so a DELETE that races startup still finds the + // session; the task unregisters itself when it ends. + let cancel = server.register_session(resource_id.clone()); + let task_server = server.clone(); + let task_resource = resource_id.clone(); + tokio::spawn(async move { + // Hold the mux registration for the session's lifetime (unregisters on exit). + let _registration = registration; + tokio::select! { + res = session.run() => session::log_session_end("whep server", res), + _ = cancel => tracing::debug!("whep session terminated by DELETE"), + } + task_server.unregister_session(&task_resource); + }); + + Ok(Response { + resource_id, + answer: sdp::render_answer(&answer), + }) +} + +fn is_sdp(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|v| v.eq_ignore_ascii_case("application/sdp")) + .unwrap_or(false) +} + +fn status_for(err: &Error) -> StatusCode { + match err { + Error::InvalidSdp(_) => StatusCode::BAD_REQUEST, + Error::UnsupportedCodec(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Error::SessionNotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} diff --git a/rs/moq-rtc/src/server/whip.rs b/rs/moq-rtc/src/server/whip.rs new file mode 100644 index 000000000..a91f38597 --- /dev/null +++ b/rs/moq-rtc/src/server/whip.rs @@ -0,0 +1,157 @@ +//! `server publish`: WHIP server (RFC 9725). +//! +//! `POST /` accepts an SDP offer (`Content-Type: application/sdp`) +//! and returns an SDP answer. The request path becomes the broadcast name on +//! the upstream publish origin. + +use axum::{ + Router, + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response as HttpResponse}, + routing::post, +}; +use str0m::{Candidate, RtcConfig}; + +use crate::{Error, Result, ingest::IngestSink, sdp, server::Server, session}; + +pub use crate::server::Response; + +/// Build the WHIP axum router. +pub fn router(server: Server) -> Router { + Router::new() + .route("/{*path}", post(handle).delete(crate::server::delete)) + .with_state(server) +} + +async fn handle( + State(server): State, + Path(path): Path, + headers: HeaderMap, + body: Bytes, +) -> HttpResponse { + match accept_offer(&server, &path, &headers, body).await { + Ok(Response { resource_id, answer }) => { + let mut response_headers = HeaderMap::new(); + response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/sdp")); + if let Ok(loc) = HeaderValue::from_str(&format!("/{path}/{resource_id}")) { + response_headers.insert(header::LOCATION, loc); + } + (StatusCode::CREATED, response_headers, answer).into_response() + } + Err(err) => { + tracing::warn!(%err, "whip request failed"); + (status_for(&err), err.to_string()).into_response() + } + } +} + +/// Router glue: enforce the WHIP `Content-Type` then hand the raw offer to +/// [`accept`], using the request path as the (unauthenticated) broadcast name. +async fn accept_offer(server: &Server, path: &str, headers: &HeaderMap, body: Bytes) -> Result { + if !is_sdp(headers) { + return Err(Error::InvalidSdp("expected Content-Type: application/sdp".into())); + } + let offer = std::str::from_utf8(&body).map_err(|err| Error::InvalidSdp(err.to_string()))?; + accept(server, server.publisher(), path, offer).await +} + +/// Accept a WHIP SDP offer and publish the negotiated WebRTC media into +/// `publisher` under `broadcast` (a path relative to that producer's root). +/// +/// This is the negotiation core behind [`router`], exposed so an embedder can +/// own the HTTP route and authentication: verify the request, resolve the +/// authorized broadcast name, scope `publisher` to the caller's grants, then hand +/// the raw SDP offer here. Taking the producer explicitly (rather than using the +/// server's) lets the embedder publish through a *scoped* origin, so the publish +/// scope is enforced by moq-net exactly as for a native session; the bundled +/// [`router`] passes the server's own (unauthenticated) producer. It parses the +/// offer, registers the broadcast (so a fast subscriber doesn't 404 in the gap +/// before the first RTP packet), registers a media session on the shared mux, +/// spawns the RTP->MoQ session, and returns the SDP answer plus an opaque +/// `resource_id` for the WHIP `Location` header. +/// +/// `offer` is the raw SDP body; the caller is responsible for checking the +/// `Content-Type: application/sdp` request header. Fails with +/// [`Error::InvalidSdp`] on a malformed offer and surfaces +/// [`moq_net::Error::Unauthorized`] (as [`Error::Other`]) if `broadcast` is +/// outside `publisher`'s scope. +pub async fn accept( + server: &Server, + publisher: &moq_net::OriginProducer, + broadcast: impl moq_net::AsPath, + offer: &str, +) -> Result { + let offer = sdp::parse_offer(offer)?; + + // Register the broadcast on the publish origin before negotiating, so a + // fast subscriber doesn't see a 404 in the gap between the SDP answer + // and the first RTP packet. + let producer = moq_net::Broadcast::new().produce(); + let consumer = producer.consume(); + // The announcement lives as long as `producer` (held by the IngestSink below); + // it is unannounced when the producer closes. A false return means the path is + // outside `publisher`'s scope. + if !publisher.publish_broadcast(broadcast, consumer) { + return Err(Error::Other(anyhow::anyhow!( + "failed to publish broadcast (outside publisher scope)" + ))); + } + + let sink = Box::new(IngestSink::new(producer)?); + + // Register a session on the shared media mux: known ICE credentials (so the + // demux routes this peer's STUN by ufrag), an inbox to read datagrams from, + // and a guard the session task holds for its lifetime (unregisters on exit). + let mux = server.mux().await?; + let (creds, inbound, registration) = mux.register(); + let mut rtc = RtcConfig::new() + .set_local_ice_credentials(creds) + .build(std::time::Instant::now()); + for addr in mux.candidates() { + let cand = Candidate::host(*addr, "udp").map_err(str0m::RtcError::from)?; + rtc.add_local_candidate(cand); + } + + let answer = rtc.sdp_api().accept_offer(offer).map_err(Error::Rtc)?; + let resource_id = sdp::new_resource_id(); + let session = session::Session::ingest(rtc, mux.socket(), mux.candidates().to_vec(), inbound, sink); + + // Register before spawning so a DELETE that races the first packet still + // finds the session; the task unregisters itself when it ends. + let cancel = server.register_session(resource_id.clone()); + let task_server = server.clone(); + let task_resource = resource_id.clone(); + tokio::spawn(async move { + // Hold the mux registration for the session's lifetime; it unregisters on exit. + let _registration = registration; + tokio::select! { + res = session.run() => session::log_session_end("whip server", res), + _ = cancel => tracing::debug!("whip session terminated by DELETE"), + } + task_server.unregister_session(&task_resource); + }); + + Ok(Response { + resource_id, + answer: sdp::render_answer(&answer), + }) +} + +fn is_sdp(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|v| v.eq_ignore_ascii_case("application/sdp")) + .unwrap_or(false) +} + +fn status_for(err: &Error) -> StatusCode { + match err { + Error::InvalidSdp(_) => StatusCode::BAD_REQUEST, + Error::UnsupportedCodec(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Error::SessionNotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} diff --git a/rs/moq-rtc/src/session.rs b/rs/moq-rtc/src/session.rs new file mode 100644 index 000000000..af4272a09 --- /dev/null +++ b/rs/moq-rtc/src/session.rs @@ -0,0 +1,557 @@ +//! str0m session driver shared by every HTTP role / media direction. +//! +//! str0m is sans-IO, so we drive the [`str0m::Rtc`] instance from a tokio +//! task that owns a UDP socket. [`Session::run`] alternates between +//! [`Rtc::poll_output`] (drain pending transmits / events) and +//! [`Rtc::handle_input`] (feed UDP packets or timeouts). +//! +//! The session itself doesn't care whether the [`Rtc`] was populated by +//! accepting an SDP offer (server side) or by minting one and posting it +//! to a remote URL (client side), or whether the media flow is RTP-in +//! ([`MediaSink`]) or RTP-out ([`crate::egress::EgressSource`]). + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Instant; + +use str0m::{Event, IceConnectionState, Input, Output, Rtc, net::Receive}; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; + +use crate::egress::{EgressSource, WriteRequest}; +use crate::{Error, Result, codec}; + +/// One inbound UDP datagram plus its source address, the unit fed to a session. +/// The [`server`](crate::server) paths get these from the shared-socket demux +/// (`crate::server::mux`); the client paths get them from a 1:1 reader +/// ([`spawn_socket_reader`]). +pub(crate) type Packet = (Vec, SocketAddr); + +/// Bound on a session's inbound datagram queue, sized like a socket buffer: +/// past this, datagrams are dropped rather than buffered (WebRTC tolerates loss +/// and a stalled session must not grow memory without limit). +pub(crate) const SESSION_INBOX: usize = 256; + +/// str0m's outbound video buffer depth (packets), which also backs NACK resends. +/// Raised above the str0m default (1000) so a late-joining peer can recover a +/// large keyframe and the rest of the current group via NACK; see +/// [`rtc_config_with_codecs`]. +const EGRESS_SEND_BUFFER_VIDEO: usize = 3000; + +/// Receives `MediaData` events from str0m and dispatches to the right codec +/// [`Bridge`](codec::Bridge). Used as the per-session sink in [`Session::run`] +/// for any flow where RTP arrives from the peer (`server publish` / WHIP +/// server, `client subscribe` / WHEP client). +pub trait MediaSink: Send { + /// Called once str0m has confirmed which codec is on which `mid`. + fn on_track( + &mut self, + mid: str0m::media::Mid, + kind: str0m::media::MediaKind, + codec: str0m::format::Codec, + audio_params: Option<(u32, u32)>, + ) -> Result<()>; + + /// Called on each [`MediaData`](str0m::media::MediaData) event. The session + /// loop has already converted the timestamp to microseconds. + fn on_frame(&mut self, mid: str0m::media::Mid, frame: codec::Frame) -> Result<()>; +} + +/// What the session does with the negotiated media stream. +#[non_exhaustive] +pub enum MediaRole { + /// RTP-in: dispatch peer frames into a [`MediaSink`]. + Ingest(Box), + /// RTP-out: pull frames from a [`crate::egress::EgressSource`] and forward to the peer. + Egress(Box), +} + +/// Drives a [`Rtc`] instance until it ends. +/// +/// The caller pre-populates the `Rtc` with whatever SDP exchange they need. +/// Sends go out the (possibly shared) `socket`; inbound datagrams arrive on +/// `inbound` rather than being read off the socket directly, so several +/// sessions can share one socket behind the `crate::server::mux`. +pub struct Session { + rtc: Rtc, + /// Send side. Shared across sessions on the server (the mux socket); owned + /// 1:1 on the client. Receiving happens via `inbound`, not this socket. + socket: Arc, + /// The local ICE candidates we advertised. Each inbound datagram is tagged + /// (for str0m) with the candidate whose address family matches the packet's + /// source, so a dual-stack peer reaching us over IPv6 isn't told the packet + /// arrived on an IPv4 host candidate. MUST be the advertised candidates, not + /// the socket's bind address: str0m drops a STUN binding request whose + /// destination doesn't match a host candidate ("unknown interface"), and the + /// shared mux socket binds a wildcard (`0.0.0.0`) while advertising concrete + /// IPs. Never empty (falls back to the bound address). + locals: Vec, + /// Inbound datagrams routed to this session (demux on the server, a 1:1 + /// reader on the client). `None` from `recv` means every sender dropped, so + /// the session is done. + inbound: mpsc::Receiver, + role: MediaRole, + /// Egress write requests. `Some` only for [`MediaRole::Egress`] + /// sessions; pumps send frames here, the main loop forwards them into + /// str0m's [`Writer`](str0m::media::Writer). + writes_rx: Option>, + /// Rebases each ingested track's raw RTP timestamps onto one session + /// timeline so audio and video stay in sync. Unused by egress sessions. + clock: IngestClock, +} + +impl Session { + /// Convenience for the ingest case (WHIP server, WHEP client). `locals` are the + /// advertised ICE candidates (see the field docs), not the socket bind. + pub fn ingest( + rtc: Rtc, + socket: Arc, + locals: Vec, + inbound: mpsc::Receiver, + sink: Box, + ) -> Self { + Self { + rtc, + socket, + locals, + inbound, + role: MediaRole::Ingest(sink), + writes_rx: None, + clock: IngestClock::default(), + } + } + + /// Convenience for the egress case (WHEP server, WHIP client). `locals` are the + /// advertised ICE candidates (see the field docs), not the socket bind. + pub fn egress( + rtc: Rtc, + socket: Arc, + locals: Vec, + inbound: mpsc::Receiver, + mut source: EgressSource, + ) -> Self { + let writes_rx = source.take_writes(); + Self { + rtc, + socket, + locals, + inbound, + role: MediaRole::Egress(Box::new(source)), + writes_rx: Some(writes_rx), + clock: IngestClock::default(), + } + } + + pub async fn run(mut self) -> Result<()> { + loop { + // A dead Rtc (DTLS/SDP failure, explicit disconnect) makes poll_output + // return a never-firing timeout instead of erroring, which would hang + // this task forever holding the broadcast announcement + mux + // registration. Bail so those release. + if !self.rtc.is_alive() { + return Err(Error::SessionClosed); + } + + let timeout = match self.rtc.poll_output().map_err(Error::Rtc)? { + Output::Timeout(t) => t, + Output::Transmit(t) => { + if let Err(err) = self.socket.send_to(&t.contents, t.destination).await { + tracing::warn!(%err, dst = %t.destination, "send failed"); + } + continue; + } + Output::Event(event) => { + self.handle_event(event)?; + continue; + } + }; + + let now = Instant::now(); + let duration = timeout.saturating_duration_since(now); + if duration.is_zero() { + self.rtc.handle_input(Input::Timeout(now)).map_err(Error::Rtc)?; + continue; + } + + // Wait for the earliest of: an inbound UDP packet, an egress + // write request (if egress), or the str0m-requested timeout. + tokio::select! { + biased; + + // Egress writes get drained promptly. Without `biased` an + // idle socket select could starve them. + Some(req) = async { + match self.writes_rx.as_mut() { + Some(rx) => rx.recv().await, + None => std::future::pending::>().await, + } + } => { + crate::egress::dispatch(&mut self.rtc, req, Instant::now()); + } + + packet = self.inbound.recv() => { + match packet { + Some((data, src)) => { + let now = Instant::now(); + // Tag the packet with the advertised candidate matching its + // address family, not the socket bind (see the `locals` docs). + let local = pick_local(&self.locals, src); + let recv = Receive::new(str0m::net::Protocol::Udp, src, local, &data) + .map_err(Error::RtcInput)?; + self.rtc.handle_input(Input::Receive(now, recv)).map_err(Error::Rtc)?; + } + // Every sender dropped: the demux unregistered us (or the + // 1:1 reader stopped). Nothing more will arrive, so end. + None => return Err(Error::SessionClosed), + } + } + + _ = tokio::time::sleep(duration) => { + self.rtc + .handle_input(Input::Timeout(Instant::now())) + .map_err(Error::Rtc)?; + } + } + } + } + + fn handle_event(&mut self, event: Event) -> Result<()> { + match event { + Event::IceConnectionStateChange(state) => { + tracing::debug!(?state, "ice state"); + if state == IceConnectionState::Disconnected { + return Err(Error::SessionClosed); + } + } + Event::MediaAdded(added) => self.handle_media_added(added)?, + Event::MediaData(data) => { + // `clock` and `role` are disjoint fields, so the borrow checker lets + // us rebase the (random, per-track) RTP base and feed the sink in one + // block; egress sessions never get here so the clock stays untouched. + if let MediaRole::Ingest(sink) = &mut self.role { + let media_us = media_time_to_micros(&data.time); + let timestamp_us = self.clock.normalize(data.mid, data.network_time, media_us); + sink.on_frame( + data.mid, + codec::Frame { + timestamp_us, + payload: data.data.into(), + }, + )?; + } + } + Event::KeyframeRequest(req) => { + // PLI / FIR from the egress peer. For v1 we just log and + // rely on the next natural keyframe from the MoQ source. + tracing::debug!(?req, "keyframe request from peer"); + } + _ => {} + } + Ok(()) + } + + fn handle_media_added(&mut self, added: str0m::media::MediaAdded) -> Result<()> { + // str0m's CodecConfig is the negotiated set; pick the first + // codec advertised for this `mid`. + let pt = self.rtc.media(added.mid).and_then(|m| m.remote_pts().first().copied()); + let params = pt.and_then(|pt| self.rtc.codec_config().params().iter().find(|p| p.pt() == pt).copied()); + let params = match params { + Some(p) => p, + None => { + tracing::warn!(?added.mid, "no codec params for media; ignoring"); + return Ok(()); + } + }; + let spec = params.spec(); + let codec = spec.codec; + + match &mut self.role { + MediaRole::Ingest(sink) => { + let audio_params = if codec.is_audio() { + Some((spec.clock_rate.get(), spec.channels.unwrap_or(1) as u32)) + } else { + None + }; + sink.on_track(added.mid, added.kind, codec, audio_params)?; + } + MediaRole::Egress(source) => { + source.on_track(added.mid, codec, params.pt(), spec.clock_rate)?; + } + } + Ok(()) + } +} + +/// Per-session clock that rebases each ingested track's raw RTP timestamps onto +/// one timeline so audio and video stay in sync. +/// +/// str0m hands us the RTP header timestamp verbatim +/// ([`MediaData::time`](str0m::media::MediaData::time)). Per RFC 3550 that base +/// is random and independent for each track, and str0m applies no RTCP +/// sender-report correlation, so publishing the values as-is would desync audio +/// from video (their bases differ by hours) and start the broadcast at an +/// arbitrary offset. We anchor each track on its first frame to that packet's +/// arrival time (relative to the first frame seen in the whole session), then +/// advance within the track by the RTP delta (str0m extends the 32-bit RTP +/// timestamp with a roll-over counter, so the delta is wrap-safe). The first +/// frame of the session maps to 0. +#[derive(Default)] +pub(crate) struct IngestClock { + /// Arrival time of the first frame seen in the session; the timeline origin. + epoch: Option, + /// Per-track additive offset (microseconds) applied to the raw RTP time. + offsets: HashMap, +} + +impl IngestClock { + /// Map a raw RTP-derived microsecond timestamp onto the session timeline. + /// `arrival` is the packet's network time + /// ([`MediaData::network_time`](str0m::media::MediaData::network_time)). + fn normalize(&mut self, mid: str0m::media::Mid, arrival: Instant, media_us: u64) -> u64 { + let epoch = *self.epoch.get_or_insert(arrival); + let offset = *self.offsets.entry(mid).or_insert_with(|| { + // Signed wall delta from the epoch: a track whose first frame we dequeue + // after the epoch frame may have actually arrived *before* it, and that + // lead must pull its timeline earlier (not clamp to the epoch via an + // unsigned subtraction) so it stays in sync. + let wall_us = if arrival >= epoch { + arrival.duration_since(epoch).as_micros() as i64 + } else { + -(epoch.duration_since(arrival).as_micros() as i64) + }; + wall_us - media_us as i64 + }); + (media_us as i64 + offset).max(0) as u64 + } +} + +/// Log a finished session at the right level: an ordinary peer disconnect +/// ([`Error::SessionClosed`]) is debug, a genuine failure is a warning. Keeps +/// normal WebRTC churn out of the warning stream. `role` labels the path +/// (e.g. `"whip server"`). +pub(crate) fn log_session_end(role: &str, result: Result<()>) { + match result { + Ok(()) | Err(Error::SessionClosed) => tracing::debug!(role, "session ended"), + Err(err) => tracing::warn!(%err, role, "session ended"), + } +} + +/// Pick the advertised local candidate to tag an inbound packet with: the first +/// one whose address family matches `src`, falling back to the first candidate +/// (the list is never empty). Keeps a dual-stack peer's packets tagged with a +/// same-family host candidate so str0m's ICE pairing stays consistent. +fn pick_local(locals: &[SocketAddr], src: SocketAddr) -> SocketAddr { + locals + .iter() + .find(|l| l.is_ipv4() == src.is_ipv4()) + .copied() + .unwrap_or(locals[0]) +} + +/// Convert a str0m [`MediaTime`](str0m::media::MediaTime) to microseconds. +fn media_time_to_micros(time: &str0m::media::MediaTime) -> u64 { + // MediaTime stores `numer / denom` seconds; cast through i128 so the + // product doesn't overflow at 90 kHz video timestamps. + let numer = time.numer() as i128; + let denom = time.denom() as i128; + if denom == 0 { + return 0; + } + let micros = (numer.saturating_mul(1_000_000)) / denom; + micros.max(0) as u64 +} + +/// Type-erased map of `Mid` -> codec bridge, populated as `MediaAdded` +/// events arrive on the ingest side. +pub(crate) struct Bridges { + inner: HashMap>, +} + +impl Bridges { + pub fn new() -> Self { + Self { inner: HashMap::new() } + } + + pub fn insert(&mut self, mid: str0m::media::Mid, bridge: Box) { + self.inner.insert(mid, bridge); + } + + pub fn push(&mut self, mid: str0m::media::Mid, frame: codec::Frame) -> Result<()> { + if let Some(bridge) = self.inner.get_mut(&mid) { + bridge.push(frame)?; + } + Ok(()) + } +} + +/// Build a [`Rtc`] with `CodecConfig` restricted to the supplied codecs. +/// +/// Used by the two egress paths so we don't advertise codecs we have no +/// source for in the catalog (WHIP client) or accept incoming codecs we +/// can't fulfil (WHEP server). For both, the negotiated SDP intersects with +/// what we can actually deliver, so `MediaAdded` only fires for codecs that +/// [`crate::egress::EgressSource`] can match to a rendition. +pub fn rtc_config_with_codecs(codecs: &[str0m::format::Codec]) -> str0m::RtcConfig { + use str0m::format::Codec; + // str0m fulfils NACK resends from the video send buffer (default 1000 + // packets). MoQ has no PLI path back to the publisher, so a late joiner's + // recovery is whatever the peer can NACK out of this buffer while the current + // group is still in flight. Widen it so a large keyframe plus the rest of the + // group stays recoverable instead of aging out after ~1000 packets. + let mut config = str0m::RtcConfig::new() + .clear_codecs() + .set_send_buffer_video(EGRESS_SEND_BUFFER_VIDEO); + for c in codecs { + config = match c { + Codec::Opus => config.enable_opus(true), + Codec::H264 => config.enable_h264(true), + Codec::H265 => config.enable_h265(true), + Codec::Vp8 => config.enable_vp8(true), + Codec::Vp9 => config.enable_vp9(true), + Codec::Av1 => config.enable_av1(true), + // Any other codec str0m grows is one we have no egress source for. + _ => config, + }; + } + config +} + +/// Build a codec-restricted [`Rtc`] for the client egress path (which lets +/// str0m mint its own ICE credentials). The server egress path uses +/// [`rtc_config_with_codecs`] directly so it can inject the mux's known +/// credentials before building. +pub fn rtc_with_codecs(codecs: &[str0m::format::Codec]) -> Rtc { + rtc_config_with_codecs(codecs).build(std::time::Instant::now()) +} + +/// Bind an ephemeral UDP socket for a single client session and return it +/// (shared with its [reader task](spawn_socket_reader)) plus the ICE candidates +/// to advertise. +/// +/// The client paths are 1:1 (one socket per dialed session, no demux); the +/// server paths share one socket via `crate::server::mux` instead. `advertise` +/// IPs are used verbatim (reusing the bound port); empty falls back to whatever +/// address the OS picked (loopback only). +pub async fn bind_udp(advertise: &[SocketAddr]) -> Result<(Arc, Vec)> { + let socket = UdpSocket::bind(("0.0.0.0", 0)).await?; + let local = socket.local_addr()?; + let candidates = if advertise.is_empty() { + vec![local] + } else { + // Reuse the bound port across each advertised IP, since str0m's ICE + // agent picks the destination port from the candidate it's pairing + // against. + advertise + .iter() + .map(|addr| SocketAddr::new(addr.ip(), local.port())) + .collect() + }; + Ok((Arc::new(socket), candidates)) +} + +/// Spawn a 1:1 reader pumping every datagram from `socket` into a channel, for +/// the client paths (one socket per session, so no demux is needed). Mirrors the +/// inbound side of `crate::server::mux` for a single session. +pub fn spawn_socket_reader(socket: Arc) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(SESSION_INBOX); + tokio::spawn(async move { + let mut buf = vec![0u8; 65_535]; + loop { + match socket.recv_from(&mut buf).await { + // Bounded like a socket buffer: drop on full, stop once the + // session's receiver is gone. + Ok((len, src)) => { + if let Err(mpsc::error::TrySendError::Closed(_)) = tx.try_send((buf[..len].to_vec(), src)) { + break; + } + } + Err(err) => { + tracing::warn!(%err, "webrtc client socket recv failed"); + break; + } + } + } + }); + rx +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use str0m::media::Mid; + + use super::*; + + #[test] + fn pick_local_matches_address_family() { + let v4: SocketAddr = "1.2.3.4:5000".parse().unwrap(); + let v6: SocketAddr = "[2001:db8::1]:5000".parse().unwrap(); + let locals = vec![v4, v6]; + let src_v4: SocketAddr = "9.9.9.9:1".parse().unwrap(); + let src_v6: SocketAddr = "[2001:db8::2]:1".parse().unwrap(); + assert_eq!(pick_local(&locals, src_v4), v4); + assert_eq!(pick_local(&locals, src_v6), v6); + // No same-family candidate falls back to the first. + assert_eq!(pick_local(&[v4], src_v6), v4); + } + + #[test] + fn ingest_clock_rebases_first_frame_to_zero() { + let mut clock = IngestClock::default(); + let mid = Mid::from("0"); + let t0 = Instant::now(); + // Raw RTP base is a large random value; the first frame must map to 0. + assert_eq!(clock.normalize(mid, t0, 5_000_000_000), 0); + } + + #[test] + fn ingest_clock_tracks_rtp_delta_within_track() { + let mut clock = IngestClock::default(); + let mid = Mid::from("0"); + let t0 = Instant::now(); + assert_eq!(clock.normalize(mid, t0, 5_000_000_000), 0); + // A later frame advances by the RTP delta, not by arrival jitter. + let arrival = t0 + Duration::from_millis(17); // jittered arrival, ignored after anchor + assert_eq!(clock.normalize(mid, arrival, 5_000_020_000), 20_000); + } + + #[test] + fn ingest_clock_keeps_tracks_in_sync_via_arrival() { + let mut clock = IngestClock::default(); + let audio = Mid::from("0"); + let video = Mid::from("1"); + let t0 = Instant::now(); + // Audio anchors the session at 0 with its own random RTP base. + assert_eq!(clock.normalize(audio, t0, 1_000_000_000), 0); + // Video's first frame arrives 5 ms later with an unrelated RTP base; it + // must land at +5 ms on the shared timeline, not at video's raw base. + let video_arrival = t0 + Duration::from_millis(5); + assert_eq!(clock.normalize(video, video_arrival, 8_000_000_000), 5_000); + // And then track its own RTP delta. + assert_eq!( + clock.normalize(video, video_arrival + Duration::from_millis(33), 8_000_033_000), + 38_000 + ); + } + + #[test] + fn ingest_clock_handles_track_arriving_before_epoch() { + let mut clock = IngestClock::default(); + let audio = Mid::from("0"); + let video = Mid::from("1"); + let t0 = Instant::now(); + // Audio's MediaData is dequeued first and sets the epoch at t0. + assert_eq!(clock.normalize(audio, t0, 1_000_000), 0); + // Video's first frame actually arrived 5 ms *before* the epoch. Its lead + // pulls the start below zero (clamped to 0), and a frame 33 ms into video + // lands 28 ms onto the shared timeline (33 ms - the 5 ms head start). + let video_arrival = t0 - Duration::from_millis(5); + assert_eq!(clock.normalize(video, video_arrival, 8_000_000), 0); + assert_eq!( + clock.normalize(video, video_arrival + Duration::from_millis(33), 8_033_000), + 28_000 + ); + } +} diff --git a/rs/moq-rtc/tests/bitstream.rs b/rs/moq-rtc/tests/bitstream.rs new file mode 100644 index 000000000..a0a28b324 --- /dev/null +++ b/rs/moq-rtc/tests/bitstream.rs @@ -0,0 +1,212 @@ +//! Bitstream-shape tests that don't need a live WebRTC peer. +//! +//! The H.264 Annex-B -> AVCC conversion is provided by `moq_mux::codec::h264`, +//! but the WHIP path depends on the importer parsing the SPS for the catalog +//! and accepting Annex-B input via the bridge. These tests guard against +//! regressions in the contract the moq-rtc bridge depends on. + +use bytes::{Bytes, BytesMut}; + +const START_CODE_4: &[u8] = &[0, 0, 0, 1]; + +fn annexb(nals: &[&[u8]]) -> Bytes { + let mut buf = BytesMut::new(); + for nal in nals { + buf.extend_from_slice(START_CODE_4); + buf.extend_from_slice(nal); + } + buf.freeze() +} + +#[tokio::test(start_paused = true)] +async fn h264_annexb_frame_publishes_catalog_entry() { + // Real SPS+PPS pair lifted from moq-mux's avc3 catalog test. Anything + // shorter and h264-parser's RBSP decoder runs out of bytes parsing + // vui_parameters; not worth synthesizing a smaller one by hand. + let sps: &[u8] = &[ + 0x67, 0x42, 0xc0, 0x1f, 0xda, 0x01, 0x40, 0x16, 0xe9, 0xb8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x07, 0xd0, 0x00, + 0x01, 0xd4, 0xc0, 0x80, + ]; + let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + + let frame = annexb(&[sps, pps, idr]); + + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let catalog = moq_mux::catalog::Producer::new(&mut producer).expect("catalog"); + + let mut bridge = moq_rtc::codec::h264::Bridge::new(producer, catalog.clone()).expect("bridge"); + + let codec_frame = moq_rtc::codec::Frame { + timestamp_us: 0, + payload: frame, + }; + moq_rtc::codec::Bridge::push(&mut bridge, codec_frame).expect("push"); + + let snapshot = catalog.snapshot(); + assert_eq!( + snapshot.video.renditions.len(), + 1, + "one video rendition must land in catalog" + ); + + let cfg = snapshot.video.renditions.values().next().unwrap(); + let hang::catalog::VideoCodec::H264(h264) = &cfg.codec else { + panic!("expected H.264 video config, got {:?}", cfg.codec); + }; + assert!(h264.inline, "WHIP path uses Avc3 (inline SPS/PPS)"); + assert_eq!(h264.profile, sps[1], "profile_idc from SPS"); + assert_eq!(h264.level, sps[3], "level_idc from SPS"); +} + +#[tokio::test(start_paused = true)] +async fn opus_frame_publishes_catalog_entry() { + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let catalog = moq_mux::catalog::Producer::new(&mut producer).expect("catalog"); + + let mut bridge = moq_rtc::codec::opus::Bridge::new(producer, catalog.clone(), 48_000, 2).expect("bridge"); + + let payload = Bytes::from_static(&[0xfc, 0xff, 0xfe]); // arbitrary 3-byte Opus packet + let codec_frame = moq_rtc::codec::Frame { + timestamp_us: 20_000, + payload, + }; + moq_rtc::codec::Bridge::push(&mut bridge, codec_frame).expect("push"); + + let snapshot = catalog.snapshot(); + assert_eq!(snapshot.audio.renditions.len(), 1); + let cfg = snapshot.audio.renditions.values().next().unwrap(); + assert_eq!(cfg.sample_rate, 48_000); + assert_eq!(cfg.channel_count, 2); + assert!(matches!(cfg.codec, hang::catalog::AudioCodec::Opus)); +} + +#[tokio::test(start_paused = true)] +async fn vp9_keyframe_flag_from_uncompressed_header() { + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let catalog = moq_mux::catalog::Producer::new(&mut producer).expect("catalog"); + + let mut bridge = moq_rtc::codec::vp9::Bridge::new(producer, catalog.clone()).expect("bridge"); + + // VP9 uncompressed header: frame_type is bit 2. 0 = keyframe, 1 = inter. + // Byte with bit 2 cleared is a keyframe; with bit 2 set is an inter frame. + let keyframe_byte = 0b1000_0010; // frame_marker=10, profile bits, frame_type=0 + let interframe_byte = 0b1000_0110; // same shape but frame_type=1 + + moq_rtc::codec::Bridge::push( + &mut bridge, + moq_rtc::codec::Frame { + timestamp_us: 0, + payload: Bytes::from(vec![keyframe_byte, 0, 0]), + }, + ) + .expect("keyframe accepted"); + + // A non-keyframe right after a keyframe must not panic; the underlying + // container Producer requires keyframes start groups, and a stray + // inter-frame following a keyframe should extend the current group. + moq_rtc::codec::Bridge::push( + &mut bridge, + moq_rtc::codec::Frame { + timestamp_us: 33_000, + payload: Bytes::from(vec![interframe_byte, 0, 0]), + }, + ) + .expect("interframe accepted"); + + assert_eq!(catalog.snapshot().video.renditions.len(), 1, "vp9 rendition announced"); +} + +// ── Egress (RTP-out) round-trip tests ───────────────────────────────────── +// +// Each test feeds the *ingest* bridge with a representative codec frame, +// then sets up an `EgressSource` against the same broadcast and walks the +// catalog rendition through `codec::Track::next()`. Verifies that the +// emitted payload is in the shape str0m's Frame API expects: +// +// - Opus / VP8 / VP9: passthrough. +// - H.264 with avc3 storage (inline SPS/PPS): passthrough. +// - H.264 with avc1 storage: length-prefix -> start-code with SPS+PPS +// prefixed on every keyframe (regression in moq-mux already covers this; +// this test confirms moq-rtc's wrapper plumbs through correctly). + +#[tokio::test(start_paused = true)] +async fn egress_opus_passthrough() { + // Build an opus broadcast via the ingest bridge. + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let catalog = moq_mux::catalog::Producer::new(&mut producer).expect("catalog"); + let mut bridge = moq_rtc::codec::opus::Bridge::new(producer.clone(), catalog.clone(), 48_000, 2).expect("bridge"); + + let payload = Bytes::from_static(&[0xfc, 0xff, 0xfe]); + moq_rtc::codec::Bridge::push( + &mut bridge, + moq_rtc::codec::Frame { + timestamp_us: 20_000, + payload: payload.clone(), + }, + ) + .expect("push"); + + // Snapshot the catalog while the bridge is still alive (Drop tears down + // the rendition entry). Open the egress track from the snapshot first. + let snapshot = catalog.snapshot(); + let (name, _) = snapshot.audio.renditions.iter().next().expect("rendition"); + let consumer = producer.consume(); + let mut track = moq_rtc::codec::Track::opus(&consumer, name).await.expect("opus track"); + + let frame = track.next().await.expect("ok").expect("frame"); + assert_eq!(frame.timestamp_us, 20_000); + assert_eq!(frame.payload.as_ref(), payload.as_ref()); +} + +#[tokio::test(start_paused = true)] +async fn egress_h264_avc3_passthrough() { + let sps: &[u8] = &[ + 0x67, 0x42, 0xc0, 0x1f, 0xda, 0x01, 0x40, 0x16, 0xe9, 0xb8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x07, 0xd0, 0x00, + 0x01, 0xd4, 0xc0, 0x80, + ]; + let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let catalog = moq_mux::catalog::Producer::new(&mut producer).expect("catalog"); + + let mut bridge = moq_rtc::codec::h264::Bridge::new(producer.clone(), catalog.clone()).expect("bridge"); + moq_rtc::codec::Bridge::push( + &mut bridge, + moq_rtc::codec::Frame { + timestamp_us: 0, + payload: annexb(&[sps, pps, idr]), + }, + ) + .expect("push"); + + let snapshot = catalog.snapshot(); + let (name, config) = snapshot.video.renditions.iter().next().expect("rendition"); + let consumer = producer.consume(); + let mut track = moq_rtc::codec::Track::video(&consumer, name, config) + .await + .expect("h264 track"); + + let frame = track.next().await.expect("ok").expect("frame"); + // avc3 storage means catalog `description` is empty; the egress track + // is passthrough, so the bitstream is whatever the ingest bridge wrote + // (Annex-B with SPS/PPS prepended ahead of the IDR). + assert!( + frame.payload.windows(4).any(|w| w == [0, 0, 0, 1]), + "Annex-B start codes preserved" + ); + assert!( + frame.payload.windows(sps.len()).any(|w| w == sps), + "SPS NAL present in egress frame" + ); + assert!( + frame.payload.windows(idr.len()).any(|w| w == idr), + "IDR NAL present in egress frame" + ); +} From 86ff3161178603386f0e3606adffebe4a3f12948 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Jun 2026 10:06:45 -0700 Subject: [PATCH 13/34] fix(nix): give moq-relay's check phase a CA bundle via cacert (#1919) Co-authored-by: Claude Opus 4.8 --- nix/overlay.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nix/overlay.nix b/nix/overlay.nix index 30b79b123..8f8e34371 100644 --- a/nix/overlay.nix +++ b/nix/overlay.nix @@ -46,6 +46,12 @@ let # jemalloc's configure uses -O0 test builds, which conflict with # Nix's _FORTIFY_SOURCE hardening (requires -O). hardeningDisable = [ "fortify" ]; + # Auth::new builds a rustls client config up front, which loads native + # roots and now errors when none are found. The build sandbox has no + # system trust store, so point rustls-native-certs at cacert's bundle + # for the check phase (even the http-only auth tests hit this path). + nativeBuildInputs = [ final.cacert ]; + SSL_CERT_FILE = "${final.cacert}/etc/ssl/certs/ca-bundle.crt"; }; moqCliArgs = crateInfo ../rs/moq-cli/Cargo.toml // { From 72b2b254a874032a3daf8834a33d9e3f25960819 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Jun 2026 12:03:24 -0700 Subject: [PATCH 14/34] [codex] fix moq-srt negative pacing offsets (#1922) Co-authored-by: Codex --- rs/moq-srt/src/server.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/rs/moq-srt/src/server.rs b/rs/moq-srt/src/server.rs index 284ebd819..d6f6094b9 100644 --- a/rs/moq-srt/src/server.rs +++ b/rs/moq-srt/src/server.rs @@ -372,7 +372,10 @@ fn pace( ts: moq_mux::container::Timestamp, now: Instant, ) -> Paced { - let send_at = anchor + Duration::from(ts).saturating_sub(Duration::from(base)); + let send_at = match ts.checked_sub(base) { + Ok(offset) => anchor + Duration::from(offset), + Err(_) => anchor.checked_sub(Duration::from(base - ts)).unwrap_or(anchor), + }; if send_at > now { // Media outran wall-clock: re-anchor so this newest frame is the live edge. Paced { @@ -469,6 +472,21 @@ mod tests { jittered.anchor, edge.anchor, "no re-anchor when media is behind wall-clock" ); + + // A reordered B-frame can carry a PTS before the re-anchored live edge. Keep + // that earlier media instant instead of flattening it onto the anchor. + let reordered = pace( + edge.anchor, + edge.base, + ms(4_099), + edge.anchor + Duration::from_millis(20), + ); + assert_eq!( + reordered.send_at, + edge.anchor - Duration::from_millis(33), + "a reordered frame can pace before the anchor" + ); + assert_eq!(reordered.anchor, edge.anchor, "no re-anchor when media trails the edge"); } fn sid(s: &str) -> StreamId { From abc59c3d9db977379b3d67c54ef90ca61ab2db64 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Jun 2026 12:48:10 -0700 Subject: [PATCH 15/34] Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) (#1918) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Luke Curley --- Cargo.lock | 741 ++++++++------- doc/concept/standard/msf.md | 9 +- doc/lib/rs/crate/moq-mux.md | 3 + js/msf/src/catalog.test.ts | 138 +++ js/msf/src/catalog.ts | 96 +- js/msf/tsconfig.json | 3 +- rs/hang/examples/video.rs | 3 + rs/hang/src/catalog/audio/codec.rs | 23 + rs/hang/src/catalog/video/codec.rs | 26 + rs/libmoq/src/audio.rs | 4 +- rs/libmoq/src/consume.rs | 4 +- rs/libmoq/src/publish.rs | 73 +- rs/moq-audio/src/producer.rs | 11 +- rs/moq-boy/src/main.rs | 10 +- rs/moq-boy/src/video.rs | 8 +- rs/moq-cli/src/publish.rs | 72 +- rs/moq-cli/src/subscribe.rs | 12 +- rs/moq-ffi/src/audio.rs | 2 +- rs/moq-ffi/src/consumer.rs | 4 +- rs/moq-ffi/src/media.rs | 2 +- rs/moq-ffi/src/producer.rs | 197 ++-- rs/moq-gst/src/sink/pad.rs | 76 +- rs/moq-msf/CHANGELOG.md | 4 + rs/moq-msf/README.md | 4 +- rs/moq-msf/src/lib.rs | 607 ++++++++++--- rs/moq-mux/CHANGELOG.md | 4 + rs/moq-mux/Cargo.toml | 4 +- rs/moq-mux/src/catalog/consumer.rs | 63 ++ rs/moq-mux/src/catalog/filter.rs | 317 +++++++ rs/moq-mux/src/catalog/format.rs | 11 - rs/moq-mux/src/catalog/hang/consumer.rs | 40 +- rs/moq-mux/src/catalog/hang/ext.rs | 38 +- rs/moq-mux/src/catalog/mod.rs | 26 +- rs/moq-mux/src/catalog/msf/consumer.rs | 204 ++--- rs/moq-mux/src/catalog/msf/mod.rs | 61 ++ rs/moq-mux/src/catalog/producer.rs | 121 ++- rs/moq-mux/src/catalog/stream.rs | 57 ++ rs/moq-mux/src/catalog/target.rs | 475 ++++++++++ rs/moq-mux/src/catalog/tracks.rs | 118 +++ rs/moq-mux/src/codec/aac/import.rs | 118 +-- rs/moq-mux/src/codec/aac/mod.rs | 52 +- rs/moq-mux/src/codec/ac3.rs | 20 +- rs/moq-mux/src/codec/annexb.rs | 177 ++-- rs/moq-mux/src/codec/av1/import.rs | 602 ++++-------- rs/moq-mux/src/codec/av1/mod.rs | 121 ++- rs/moq-mux/src/codec/av1/split.rs | 343 +++++++ rs/moq-mux/src/codec/eac3.rs | 40 +- rs/moq-mux/src/codec/h264/export.rs | 327 +++++++ rs/moq-mux/src/codec/h264/import.rs | 908 +++++-------------- rs/moq-mux/src/codec/h264/mod.rs | 259 ++++-- rs/moq-mux/src/codec/h264/split.rs | 381 ++++++++ rs/moq-mux/src/codec/h265/export.rs | 175 ++++ rs/moq-mux/src/codec/h265/import.rs | 505 +++-------- rs/moq-mux/src/codec/h265/mod.rs | 316 ++++--- rs/moq-mux/src/codec/h265/split.rs | 365 ++++++++ rs/moq-mux/src/codec/legacy.rs | 147 +-- rs/moq-mux/src/codec/mp2.rs | 24 +- rs/moq-mux/src/codec/opus/import.rs | 120 +-- rs/moq-mux/src/codec/opus/mod.rs | 23 +- rs/moq-mux/src/codec/vp8/import.rs | 201 ++-- rs/moq-mux/src/codec/vp8/mod.rs | 37 +- rs/moq-mux/src/codec/vp9/import.rs | 201 ++-- rs/moq-mux/src/codec/vp9/mod.rs | 51 +- rs/moq-mux/src/container/consumer.rs | 478 +++++++--- rs/moq-mux/src/container/flv/export.rs | 10 +- rs/moq-mux/src/container/flv/export_test.rs | 145 ++- rs/moq-mux/src/container/flv/import.rs | 79 +- rs/moq-mux/src/container/flv/import_test.rs | 51 +- rs/moq-mux/src/container/fmp4/export.rs | 219 +++-- rs/moq-mux/src/container/fmp4/export_test.rs | 254 +++++- rs/moq-mux/src/container/fmp4/import.rs | 229 +++-- rs/moq-mux/src/container/fmp4/import_test.rs | 17 +- rs/moq-mux/src/container/fmp4/mod.rs | 241 ++++- rs/moq-mux/src/container/hls/import.rs | 123 ++- rs/moq-mux/src/container/hls/mod.rs | 44 + rs/moq-mux/src/container/jitter.rs | 44 +- rs/moq-mux/src/container/legacy/mod.rs | 2 + rs/moq-mux/src/container/loc/mod.rs | 29 +- rs/moq-mux/src/container/mkv/export.rs | 149 +-- rs/moq-mux/src/container/mkv/export_test.rs | 66 +- rs/moq-mux/src/container/mkv/import.rs | 140 ++- rs/moq-mux/src/container/mkv/import_test.rs | 8 +- rs/moq-mux/src/container/mkv/mod.rs | 109 +++ rs/moq-mux/src/container/mod.rs | 40 +- rs/moq-mux/src/container/producer.rs | 144 ++- rs/moq-mux/src/container/source.rs | 129 ++- rs/moq-mux/src/container/ts/catalog.rs | 4 +- rs/moq-mux/src/container/ts/export.rs | 59 +- rs/moq-mux/src/container/ts/export_test.rs | 118 ++- rs/moq-mux/src/container/ts/import.rs | 214 +++-- rs/moq-mux/src/container/ts/import_test.rs | 22 +- rs/moq-mux/src/error.rs | 99 +- rs/moq-mux/src/import.rs | 672 -------------- rs/moq-mux/src/import/container.rs | 152 ++++ rs/moq-mux/src/import/mod.rs | 33 + rs/moq-mux/src/import/track.rs | 642 +++++++++++++ rs/moq-mux/src/lib.rs | 1 - rs/moq-mux/src/track_provider.rs | 34 - rs/moq-net/src/model/broadcast.rs | 10 +- rs/moq-net/src/model/track.rs | 57 ++ rs/moq-rtc/src/codec/h264.rs | 22 +- rs/moq-rtc/src/codec/opus.rs | 9 +- rs/moq-rtc/src/codec/vp8.rs | 1 + rs/moq-rtc/src/codec/vp9.rs | 1 + rs/moq-rtmp/src/server.rs | 10 +- rs/moq-srt/src/ts.rs | 7 +- rs/moq-video/src/encode/producer.rs | 44 +- rs/moq-video/src/error.rs | 6 +- 108 files changed, 9226 insertions(+), 4925 deletions(-) create mode 100644 js/msf/src/catalog.test.ts create mode 100644 rs/moq-mux/src/catalog/consumer.rs create mode 100644 rs/moq-mux/src/catalog/filter.rs create mode 100644 rs/moq-mux/src/catalog/stream.rs create mode 100644 rs/moq-mux/src/catalog/target.rs create mode 100644 rs/moq-mux/src/catalog/tracks.rs create mode 100644 rs/moq-mux/src/codec/av1/split.rs create mode 100644 rs/moq-mux/src/codec/h264/export.rs create mode 100644 rs/moq-mux/src/codec/h264/split.rs create mode 100644 rs/moq-mux/src/codec/h265/export.rs create mode 100644 rs/moq-mux/src/codec/h265/split.rs delete mode 100644 rs/moq-mux/src/import.rs create mode 100644 rs/moq-mux/src/import/container.rs create mode 100644 rs/moq-mux/src/import/mod.rs create mode 100644 rs/moq-mux/src/import/track.rs delete mode 100644 rs/moq-mux/src/track_provider.rs diff --git a/Cargo.lock b/Cargo.lock index ff02389c5..afc630618 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,7 +155,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -166,7 +166,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -204,9 +204,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "askama" @@ -235,7 +235,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -274,7 +274,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] @@ -286,7 +286,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -299,18 +299,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "async-compression" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "async-lock" version = "3.4.2" @@ -330,7 +318,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -587,8 +575,8 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex 1.3.0", - "syn 2.0.118", + "shlex", + "syn 2.0.117", ] [[package]] @@ -602,9 +590,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blake3" @@ -640,9 +628,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", ] @@ -752,9 +740,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -770,9 +758,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.3" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] @@ -820,9 +808,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cbindgen" -version = "0.29.4" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" dependencies = [ "clap", "heck", @@ -832,21 +820,21 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.118", + "syn 2.0.117", "tempfile", "toml 0.9.12+spec-1.1.0", ] [[package]] name = "cc" -version = "1.2.65" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -906,9 +894,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.8" +version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" dependencies = [ "smallvec", "target-lexicon", @@ -939,9 +927,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.45" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -1030,7 +1018,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1050,9 +1038,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.4" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "cobs" @@ -1079,23 +1067,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compression-codecs" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" -dependencies = [ - "compression-core", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1447,7 +1418,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1481,7 +1452,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1494,7 +1465,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1505,7 +1476,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1516,7 +1487,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1562,7 +1533,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -1648,7 +1619,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1657,6 +1628,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ + "powerfmt", "serde_core", ] @@ -1678,7 +1650,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1688,7 +1660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1701,7 +1673,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1723,7 +1695,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.117", "unicode-xid", ] @@ -1760,7 +1732,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", + "block-buffer 0.12.0", "crypto-common 0.2.2", ] @@ -1803,13 +1775,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1958,7 +1930,7 @@ checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -1970,7 +1942,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2006,7 +1978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2048,7 +2020,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" dependencies = [ - "foldhash", + "foldhash 0.2.0", "libm", "portable-atomic", "siphasher", @@ -2150,6 +2122,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2174,7 +2152,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2194,9 +2172,9 @@ dependencies = [ [[package]] name = "foundations" -version = "5.7.3" +version = "5.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92619d6c02aeb0106bea8717702afa888a400088c681fd0be080dc1e029afb65" +checksum = "f8842ece95ec0b71f0dbf9cb1da218ebdc6a40536bb97ea9c1974978058912b9" dependencies = [ "anyhow", "cf-rustracing", @@ -2234,14 +2212,14 @@ dependencies = [ [[package]] name = "foundations-macros" -version = "5.7.3" +version = "5.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1a9a185152575916042458bd8b6edb06e96a71af45e0ec63cc5f7b222c21dfb" +checksum = "ef32f15128fcc7706efb662b46b92bcad51c1e92cd691320864efde9e5e218c0" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2370,7 +2348,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2410,9 +2388,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.9" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" dependencies = [ "cc", "cfg-if", @@ -2463,27 +2441,30 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasip2", + "wasip3", "wasm-bindgen", ] [[package]] name = "getset" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" dependencies = [ + "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2546,7 +2527,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -2640,7 +2621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94668bc2592732b8c2b653668ae41211d45988fb61264888b9c2d545d4bd826d" dependencies = [ "chrono", - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -2682,9 +2663,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -2750,6 +2731,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2758,7 +2748,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2769,7 +2759,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2908,9 +2898,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -3039,9 +3029,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -3105,7 +3095,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -3217,6 +3207,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3252,9 +3248,9 @@ dependencies = [ [[package]] name = "igd-next" -version = "0.17.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +checksum = "bac9a3c8278f43b4cd8463380f4a25653ac843e5b177e1d3eaf849cc9ba10d4d" dependencies = [ "attohttpc", "bytes", @@ -3337,7 +3333,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.4", + "socket2 0.6.3", "widestring", "windows-registry", "windows-result", @@ -3377,7 +3373,7 @@ dependencies = [ "derive_more 2.1.1", "ed25519-dalek", "futures-util", - "getrandom 0.4.3", + "getrandom 0.4.2", "hickory-resolver", "http", "ipnet", @@ -3397,7 +3393,7 @@ dependencies = [ "portable-atomic", "portmapper", "rand 0.10.1", - "reqwest 0.13.4", + "reqwest 0.13.3", "rustc-hash", "rustls", "rustls-pki-types", @@ -3424,7 +3420,7 @@ dependencies = [ "data-encoding-macro", "derive_more 2.1.1", "ed25519-dalek", - "getrandom 0.4.3", + "getrandom 0.4.2", "n0-error", "rand 0.10.1", "serde", @@ -3480,7 +3476,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3494,7 +3490,7 @@ dependencies = [ "cfg_aliases", "data-encoding", "derive_more 2.1.1", - "getrandom 0.4.3", + "getrandom 0.4.2", "hickory-resolver", "http", "http-body-util", @@ -3512,7 +3508,7 @@ dependencies = [ "pin-project", "postcard", "rand 0.10.1", - "reqwest 0.13.4", + "reqwest 0.13.3", "rustls", "rustls-pki-types", "serde", @@ -3550,7 +3546,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3626,7 +3622,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3654,7 +3650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -3669,12 +3665,13 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] @@ -3763,6 +3760,12 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -3850,9 +3853,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "loom" @@ -3882,16 +3885,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "m3u8-rs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03cd3335fb5f2447755d45cda9c70f76013626a9db44374973791b0926a86c3" -dependencies = [ - "chrono", - "nom 7.1.3", -] - [[package]] name = "mac-addr" version = "0.3.0" @@ -3921,9 +3914,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -3968,9 +3961,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -4168,7 +4161,6 @@ dependencies = [ "h264-parser", "hang", "kio", - "m3u8-rs", "memchr", "moq-json", "moq-loc", @@ -4177,7 +4169,6 @@ dependencies = [ "mp4-atom", "mpeg2ts", "num_enum", - "reqwest 0.12.28", "scuffle-av1", "scuffle-h265", "serde", @@ -4215,7 +4206,7 @@ dependencies = [ "rustls-webpki", "serde", "serde_with", - "socket2 0.6.4", + "socket2 0.6.3", "tempfile", "thiserror 2.0.18", "tikv-jemalloc-ctl", @@ -4334,7 +4325,7 @@ dependencies = [ "rml_rtmp", "rustls", "sd-notify", - "socket2 0.6.4", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tokio-rustls", @@ -4456,7 +4447,7 @@ checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -4546,7 +4537,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -4662,7 +4653,7 @@ dependencies = [ "objc2-system-configuration", "pin-project-lite", "serde", - "socket2 0.6.4", + "socket2 0.6.3", "time", "tokio", "tokio-util", @@ -4725,7 +4716,7 @@ dependencies = [ "pin-project-lite", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -4745,7 +4736,7 @@ dependencies = [ "derive_more 2.1.1", "enum-assoc", "fastbloom 0.17.0", - "getrandom 0.4.3", + "getrandom 0.4.2", "identity-hash", "lru-slab", "rand 0.10.1", @@ -4770,7 +4761,7 @@ checksum = "cd5a37756f168cf350d68a97c4f0158bdf3c76f10175123941569b09ab51f011" dependencies = [ "cfg_aliases", "libc", - "socket2 0.6.4", + "socket2 0.6.3", "tracing", "windows-sys 0.61.2", ] @@ -4808,7 +4799,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4874,7 +4865,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -4947,7 +4938,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5285,7 +5276,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5516,7 +5507,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5525,12 +5516,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkcs1" version = "0.7.5" @@ -5662,7 +5647,7 @@ dependencies = [ "rand 0.10.1", "serde", "smallvec", - "socket2 0.6.4", + "socket2 0.6.3", "time", "tokio", "tokio-util", @@ -5692,7 +5677,7 @@ checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5730,6 +5715,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -5745,7 +5740,29 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -5848,9 +5865,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -5858,15 +5875,15 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.4" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -5954,9 +5971,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", @@ -5965,7 +5982,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -6005,16 +6022,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -6059,7 +6076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.3", + "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -6195,14 +6212,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -6223,9 +6240,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -6267,9 +6284,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", @@ -6459,14 +6476,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -6480,9 +6497,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -6518,7 +6535,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6539,7 +6556,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6643,7 +6660,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6769,7 +6786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6825,7 +6842,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -6885,9 +6902,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64", "bs58", @@ -6905,14 +6922,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7026,12 +7043,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -7187,15 +7198,15 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smawk" -version = "0.3.3" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" @@ -7209,12 +7220,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7231,7 +7242,7 @@ checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7414,7 +7425,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7436,9 +7447,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -7462,7 +7473,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7519,9 +7530,9 @@ checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" [[package]] name = "target-lexicon" -version = "0.13.5" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "task-killswitch" @@ -7541,10 +7552,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7553,7 +7564,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7591,7 +7602,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7602,7 +7613,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7657,11 +7668,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "js-sys", "libc", "num-conv", @@ -7674,15 +7686,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -7735,7 +7747,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -7748,7 +7760,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -7851,7 +7863,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "getrandom 0.4.3", + "getrandom 0.4.2", "http", "httparse", "rand 0.10.1", @@ -7948,9 +7960,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -8041,7 +8053,6 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "async-compression", "bitflags", "bytes", "futures-core", @@ -8106,7 +8117,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8166,7 +8177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8220,9 +8231,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unarray" @@ -8244,9 +8255,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -8256,9 +8267,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uniffi" -version = "0.31.2" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eefd5468602930da46b1f49d3448c6dfc2e81295f93120f23f8174fd70267f" +checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" dependencies = [ "anyhow", "camino", @@ -8273,9 +8284,9 @@ dependencies = [ [[package]] name = "uniffi_bindgen" -version = "0.31.2" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a0c9b375d32e1365cdb2bdd7cb495eecf6fac851ddbad077412b4ee1888514" +checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" dependencies = [ "anyhow", "askama", @@ -8299,9 +8310,9 @@ dependencies = [ [[package]] name = "uniffi_build" -version = "0.31.2" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744fe15bcd3e2b1712a4573a45ce749af19cf28d69027ca5789619014955668c" +checksum = "4c39413c43b955e4aa8a4e2b34bbd1b6b5ff6bd85532b52f9eb92fbe88c14458" dependencies = [ "anyhow", "camino", @@ -8310,9 +8321,9 @@ dependencies = [ [[package]] name = "uniffi_core" -version = "0.31.2" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eec017b112701681f6fbbe5d92014b5c468eb0b177a94389de03ceec40665095" +checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" dependencies = [ "anyhow", "bytes", @@ -8322,22 +8333,22 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.31.2" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4641669b48fefbc5e80ff08c5004d9c7617fb91232131a6734ab6712779cb04c" +checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" dependencies = [ "anyhow", "indexmap 2.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] name = "uniffi_macros" -version = "0.31.2" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb8617ee814de22caf7417bf514715ba0b3f46bd9d5a5d794413fd8282cb737" +checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" dependencies = [ "camino", "fs-err 2.11.0", @@ -8345,16 +8356,16 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.118", + "syn 2.0.117", "toml 0.9.12+spec-1.1.0", "uniffi_meta", ] [[package]] name = "uniffi_meta" -version = "0.31.2" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58d5b94fc92803d21b2928bd15c6f06e57609b95caf98ea561c99cda1b6d2a25" +checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" dependencies = [ "anyhow", "siphasher", @@ -8364,9 +8375,9 @@ dependencies = [ [[package]] name = "uniffi_pipeline" -version = "0.31.2" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "032739b3ec725576914c15899dedaf080163ced86b6934566c20ec2b20ce90ca" +checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" dependencies = [ "anyhow", "heck", @@ -8377,9 +8388,9 @@ dependencies = [ [[package]] name = "uniffi_udl" -version = "0.31.2" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a1d0a0252ce1af9e8ce78ba67ac0d8937fb2bedaf10cbddd43d3614d06ec6" +checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" dependencies = [ "anyhow", "textwrap", @@ -8447,11 +8458,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -8525,7 +8536,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8555,18 +8566,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -8577,9 +8597,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -8587,9 +8607,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8597,26 +8617,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -8631,36 +8673,33 @@ dependencies = [ ] [[package]] -name = "wasmtimer" -version = "0.4.3" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "futures", - "js-sys", - "parking_lot", - "pin-utils", - "slab", - "wasm-bindgen", + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", ] [[package]] name = "web-async" -version = "0.1.4" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f56ac33e792583916a8021e43e8a7e0987f5df7abc8f8afd72fcc361048755" +checksum = "f5414b65d9a5094649bb99987bb74db71febfdfa3677b7954a0a05c99d0424e8" dependencies = [ "tokio", "tracing", "wasm-bindgen-futures", - "wasmtimer", ] [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -8770,9 +8809,9 @@ dependencies = [ [[package]] name = "web-transport-trait" -version = "0.3.6" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9959d7a7db997953305c49e0db5087fb495614a186d1cdfa98c40f9a194dbe5d" +checksum = "fc5f83d19cf6c8ba147f4e1e5935a8a115c91f6abbf714d740a83b967d558e6e" dependencies = [ "bytes", ] @@ -8788,18 +8827,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -8841,7 +8880,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8912,7 +8951,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -8923,7 +8962,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9252,12 +9291,100 @@ dependencies = [ "url", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wmi" version = "0.18.4" @@ -9376,9 +9503,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -9393,28 +9520,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9434,7 +9561,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", "synstructure", ] @@ -9455,7 +9582,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] @@ -9488,7 +9615,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.117", ] [[package]] diff --git a/doc/concept/standard/msf.md b/doc/concept/standard/msf.md index ceefbda89..d1c84724d 100644 --- a/doc/concept/standard/msf.md +++ b/doc/concept/standard/msf.md @@ -9,7 +9,12 @@ HLS/DASH playlists suck. WebRTC SDP is even worse. MSF is a replacement for both, utilizing MoQ live streams. -[MSF](https://www.ietf.org/archive/id/draft-ietf-moq-msf-00.html) is a catalog format for MoQ. +[MSF](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.html) is a catalog format for MoQ. It's similar to the [hang catalog](../layer/hang) and we'll probably merge them in the future. -[See the draft](https://www.ietf.org/archive/id/draft-ietf-moq-msf-00.html) for the latest details. +We track draft-01, which changed the catalog `version` from a number to a `"draft-XX"` string and +moved init data out of the track into a root `initDataList` referenced by `initRef`. +Our implementation hides this on the wire: the catalog API is a version-agnostic snapshot, draft-00 +catalogs still decode, and init data is always presented inline regardless of how it was carried. + +[See the draft](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.html) for the latest details. diff --git a/doc/lib/rs/crate/moq-mux.md b/doc/lib/rs/crate/moq-mux.md index 6ffce3593..501c724d1 100644 --- a/doc/lib/rs/crate/moq-mux.md +++ b/doc/lib/rs/crate/moq-mux.md @@ -54,6 +54,9 @@ See the [moq-cli source](https://github.com/moq-dev/moq/tree/main/rs/moq-cli) fo - H.264 (AVC) - requires `h264` feature - H.265 (HEVC) - requires `h265` feature +- AV1 +- VP8 +- VP9 **Audio:** diff --git a/js/msf/src/catalog.test.ts b/js/msf/src/catalog.test.ts new file mode 100644 index 000000000..3d6516f9c --- /dev/null +++ b/js/msf/src/catalog.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from "bun:test"; +import { decode, encode } from "./catalog.ts"; + +function encodeJson(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(value)); +} + +function decodeJson(raw: Uint8Array): Record { + return JSON.parse(new TextDecoder().decode(raw)); +} + +test("decodes a draft-00 catalog with a numeric version", () => { + // Example 1 from draft-ietf-moq-msf-00, trimmed. Numeric version plus unmodeled + // fields (namespace, targetLatency, generatedAt) which must be ignored. + const catalog = decode( + encodeJson({ + version: 1, + generatedAt: 1746104606044, + tracks: [ + { + name: "1080p-video", + namespace: "conference.example.com/conference123/alice", + packaging: "loc", + isLive: true, + targetLatency: 2000, + role: "video", + codec: "av01.0.08M.10.0.110.09", + width: 1920, + height: 1080, + framerate: 30, + bitrate: 1500000, + }, + ], + }), + ); + + expect(catalog.tracks).toHaveLength(1); + expect(catalog.tracks[0].codec).toBe("av01.0.08M.10.0.110.09"); +}); + +test("decodes a draft-00 catalog whose timeline tracks omit isLive", () => { + // Example 8 from draft-ietf-moq-msf-00: mediatimeline tracks omit isLive/role/codec. + const catalog = decode( + encodeJson({ + version: 1, + tracks: [ + { + name: "history", + packaging: "mediatimeline", + mimetype: "application/json", + depends: ["1080p-video"], + }, + { + name: "1080p-video", + packaging: "loc", + isLive: true, + role: "video", + codec: "av01.0.08M.10.0.110.09", + }, + ], + }), + ); + + expect(catalog.tracks).toHaveLength(2); + expect(catalog.tracks[0].isLive).toBeUndefined(); + expect(catalog.tracks[0].packaging).toBe("mediatimeline"); +}); + +test("decodes a draft-01 catalog with a string version", () => { + const catalog = decode( + encodeJson({ + version: "draft-01", + tracks: [{ name: "audio", packaging: "loc", isLive: true, role: "audio", codec: "opus" }], + }), + ); + + expect(catalog.tracks[0].role).toBe("audio"); +}); + +test("resolves draft-01 initRef into inline initData", () => { + const catalog = decode( + encodeJson({ + version: "draft-01", + initDataList: [{ id: "v0", type: "inline", data: "AQID" }], + tracks: [ + { name: "video0", packaging: "cmaf", isLive: true, role: "video", codec: "avc1.640028", initRef: "v0" }, + ], + }), + ); + + expect(catalog.tracks[0].initData).toBe("AQID"); +}); + +test("leaves initData undefined for a dangling or non-inline initRef", () => { + const catalog = decode( + encodeJson({ + version: "draft-01", + initDataList: [{ id: "v0", type: "url", data: "https://example.com/init" }], + tracks: [ + { name: "a", packaging: "cmaf", isLive: true, role: "video", codec: "avc1.640028", initRef: "missing" }, + { name: "b", packaging: "cmaf", isLive: true, role: "video", codec: "avc1.640028", initRef: "v0" }, + ], + }), + ); + + expect(catalog.tracks[0].initData).toBeUndefined(); + expect(catalog.tracks[1].initData).toBeUndefined(); +}); + +test("rejects an unsupported numeric version", () => { + // Mirrors the Rust side: any number other than 1 is rejected. + expect(() => decode(encodeJson({ version: 2, tracks: [] }))).toThrow(); +}); + +test("encode hoists and dedups init data, then round-trips", () => { + const catalog = { + tracks: [ + { name: "a", packaging: "cmaf", isLive: true, role: "video", codec: "avc1.640028", initData: "AQID" }, + { name: "b", packaging: "cmaf", isLive: true, role: "video", codec: "avc1.640028", initData: "AQID" }, + ], + }; + + const wire = decodeJson(encode(catalog)); + const list = wire.initDataList as { id: string; type: string; data: string }[]; + expect(list).toHaveLength(1); + expect(list[0].data).toBe("AQID"); + expect(wire.version).toBe("draft-01"); + + const wireTracks = wire.tracks as { initRef?: string; initData?: string }[]; + for (const t of wireTracks) { + expect(t.initRef).toBe(list[0].id); + expect(t.initData).toBeUndefined(); + } + + const parsed = decode(encode(catalog)); + expect(parsed.tracks[0].initData).toBe("AQID"); + expect(parsed.tracks[1].initData).toBe("AQID"); +}); diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index 169bdf748..c706dabc2 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -19,11 +19,14 @@ export const RoleSchema = z.union([ /** The semantic role a track plays in the presentation (e.g. "video", "audio", "caption"). */ export type Role = z.infer; -/** Zod schema describing a single track entry in an MSF catalog. */ -export const TrackSchema = z.object({ +// Shared track fields. This is the version-agnostic shape callers see: init data +// is exposed inline via `initData`, regardless of how it was carried on the wire. +const trackShape = { name: z.string(), packaging: PackagingSchema, - isLive: z.boolean(), + // draft-00 marks isLive required but omits it on mediatimeline/eventtimeline + // tracks, so accept its absence rather than reject the whole catalog. + isLive: z.optional(z.boolean()), role: z.optional(RoleSchema), codec: z.optional(z.string()), width: z.optional(z.number()), @@ -32,6 +35,7 @@ export const TrackSchema = z.object({ samplerate: z.optional(z.number()), channelConfig: z.optional(z.string()), bitrate: z.optional(z.number()), + /** Resolved base64 initialization data (draft-01's initRef indirection is resolved away). */ initData: z.optional(z.string()), renderGroup: z.optional(z.number()), altGroup: z.optional(z.number()), @@ -40,33 +44,97 @@ export const TrackSchema = z.object({ // The player's buffer must be at least this large to avoid underruns. // Mirrors the `jitter` field in the hang catalog. jitter: z.optional(z.number()), -}); +}; + +/** Zod schema describing a single track entry in an MSF catalog. */ +export const TrackSchema = z.object(trackShape); /** A single track in an MSF catalog, including its codec and media properties. */ export type Track = z.infer; -/** Zod schema for the top-level MSF catalog (version 1). */ +/** Zod schema for the top-level MSF catalog: a version-agnostic snapshot of tracks. */ export const CatalogSchema = z.object({ - version: z.literal(1), tracks: z.array(TrackSchema), }); -/** The MSF catalog: a versioned list of available tracks. */ +/** The MSF catalog: a snapshot of the available tracks. */ export type Catalog = z.infer; -/** Serialize a catalog to its JSON wire representation. */ +/** The newest MSF draft version string this package emits on the wire. */ +export const VERSION = "draft-01"; + +// --- Wire representation (internal) ----------------------------------------- +// +// The wire format hides two things from callers: the catalog `version` (number +// in draft-00, "draft-XX" string in draft-01) and init data, which draft-01 +// moved out of the track into a root `initDataList` referenced by `initRef`. + +const InitDataSchema = z.object({ + id: z.string(), + type: z.string(), + data: z.string(), +}); + +const WireTrackSchema = z.object({ + ...trackShape, + initRef: z.optional(z.string()), +}); + +const WireCatalogSchema = z.object({ + // draft-00 used the number 1; draft-01 uses a "draft-XX" string. Accept both. + version: z.union([z.literal(1), z.string()]), + tracks: z.optional(z.array(WireTrackSchema)), + initDataList: z.optional(z.array(InitDataSchema)), +}); + +/** Serialize a catalog to its JSON wire representation (draft-01). */ export function encode(catalog: Catalog): Uint8Array { - const encoder = new TextEncoder(); - return encoder.encode(JSON.stringify(catalog)); + // Hoist inline init payloads into a shared, deduplicated initDataList and + // reference each from its track via initRef (the draft-01 wire shape). + const initDataList: z.infer[] = []; + const ids = new Map(); + + const tracks = catalog.tracks.map((track) => { + const { initData, ...rest } = track; + if (initData === undefined) return rest; + + let id = ids.get(initData); + if (id === undefined) { + id = `init${initDataList.length}`; + initDataList.push({ id, type: "inline", data: initData }); + ids.set(initData, id); + } + return { ...rest, initRef: id }; + }); + + const wire: Record = { version: VERSION, tracks }; + if (initDataList.length > 0) wire.initDataList = initDataList; + + return new TextEncoder().encode(JSON.stringify(wire)); } /** Parse and validate a catalog from its JSON wire representation. Throws if invalid. */ export function decode(raw: Uint8Array): Catalog { - const decoder = new TextDecoder(); - const str = decoder.decode(raw); + const str = new TextDecoder().decode(raw); try { - const json = JSON.parse(str); - return CatalogSchema.parse(json); + const wire = WireCatalogSchema.parse(JSON.parse(str)); + + // id -> inline payload, built once so resolution is linear in the number + // of tracks rather than tracks x entries. + const inline = new Map((wire.initDataList ?? []).filter((e) => e.type === "inline").map((e) => [e.id, e.data])); + + const tracks = (wire.tracks ?? []).map(({ initRef, ...track }) => { + // Resolve draft-01 initRef into inline initData so callers never see + // the indirection. Inline initData (draft-00) is left untouched. + if (track.initData === undefined && initRef !== undefined) { + // Only inline entries are resolved here. A non-inline (e.g. `url`) initRef + // legitimately stays undefined; the consumer fetches that init out-of-band. + track.initData = inline.get(initRef); + } + return track; + }); + + return { tracks }; } catch (error) { console.warn("invalid MSF catalog", str); throw error; diff --git a/js/msf/tsconfig.json b/js/msf/tsconfig.json index 0f506334d..bb55d7c43 100644 --- a/js/msf/tsconfig.json +++ b/js/msf/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "./src" + "rootDir": "./src", + "types": ["bun"] }, "include": ["src"] } diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index 993401265..b0890aac8 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -106,6 +106,7 @@ async fn run_broadcast(origin: moq_net::OriginProducer) -> anyhow::Result<()> { timestamp: moq_mux::container::Timestamp::from_secs(1).unwrap(), payload: Bytes::from_static(b"keyframe NAL data"), keyframe: true, + duration: None, }; producer.write(frame)?; @@ -115,6 +116,7 @@ async fn run_broadcast(origin: moq_net::OriginProducer) -> anyhow::Result<()> { timestamp: moq_mux::container::Timestamp::from_secs(2).unwrap(), payload: Bytes::from_static(b"delta NAL data"), keyframe: false, + duration: None, }; producer.write(frame)?; @@ -125,6 +127,7 @@ async fn run_broadcast(origin: moq_net::OriginProducer) -> anyhow::Result<()> { timestamp: moq_mux::container::Timestamp::from_secs(3).unwrap(), payload: Bytes::from_static(b"keyframe NAL data"), keyframe: true, + duration: None, }; producer.write(frame)?; diff --git a/rs/hang/src/catalog/audio/codec.rs b/rs/hang/src/catalog/audio/codec.rs index eb9e26cfb..fa901e7f8 100644 --- a/rs/hang/src/catalog/audio/codec.rs +++ b/rs/hang/src/catalog/audio/codec.rs @@ -39,6 +39,29 @@ pub enum AudioCodec { Unknown(String), } +/// Coarse audio codec family, used for tag-only matching. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum AudioCodecKind { + AAC, + Opus, + Unknown, +} + +impl AudioCodec { + /// Return the coarse codec family for tag-only matching. + pub fn kind(&self) -> AudioCodecKind { + match self { + Self::AAC(_) => AudioCodecKind::AAC, + Self::Opus => AudioCodecKind::Opus, + // Legacy TS-bridge codecs aren't WebCodecs-decodable, so they share the + // coarse Unknown family for tag-only matching. + Self::Mp2 | Self::Ac3 | Self::Ec3 => AudioCodecKind::Unknown, + Self::Unknown(_) => AudioCodecKind::Unknown, + } + } +} + impl FromStr for AudioCodec { type Err = Error; diff --git a/rs/hang/src/catalog/video/codec.rs b/rs/hang/src/catalog/video/codec.rs index 76eb228c9..8fa206b8e 100644 --- a/rs/hang/src/catalog/video/codec.rs +++ b/rs/hang/src/catalog/video/codec.rs @@ -29,6 +29,32 @@ pub enum VideoCodec { Unknown(String), } +/// Coarse video codec family, used for tag-only matching. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum VideoCodecKind { + H264, + H265, + VP8, + VP9, + AV1, + Unknown, +} + +impl VideoCodec { + /// Return the coarse codec family for tag-only matching. + pub fn kind(&self) -> VideoCodecKind { + match self { + Self::H264(_) => VideoCodecKind::H264, + Self::H265(_) => VideoCodecKind::H265, + Self::VP9(_) => VideoCodecKind::VP9, + Self::AV1(_) => VideoCodecKind::AV1, + Self::VP8 => VideoCodecKind::VP8, + Self::Unknown(_) => VideoCodecKind::Unknown, + } + } +} + impl FromStr for VideoCodec { type Err = Error; diff --git a/rs/libmoq/src/audio.rs b/rs/libmoq/src/audio.rs index f9df31ec9..3ce874aa2 100644 --- a/rs/libmoq/src/audio.rs +++ b/rs/libmoq/src/audio.rs @@ -126,7 +126,7 @@ pub struct moq_audio_frame { #[derive(Default)] pub struct Audio { - producers: NonZeroSlab, + producers: NonZeroSlab>, consumer_tasks: NonZeroSlab>, frames: NonZeroSlab, } @@ -145,7 +145,7 @@ impl Audio { pub fn publish( &mut self, broadcast: &mut moq_net::BroadcastProducer, - catalog: moq_mux::catalog::Producer, + catalog: moq_mux::catalog::Producer, name: &str, input: moq_audio::EncoderInput, output: moq_audio::EncoderOutput, diff --git a/rs/libmoq/src/consume.rs b/rs/libmoq/src/consume.rs index 766d223bf..e6675ef5e 100644 --- a/rs/libmoq/src/consume.rs +++ b/rs/libmoq/src/consume.rs @@ -7,7 +7,7 @@ use crate::{Error, Id, NonZeroSlab, State, moq_audio_config, moq_frame, moq_sect struct ConsumeCatalog { broadcast: moq_net::BroadcastConsumer, - catalog: moq_mux::catalog::hang::Catalog, + catalog: moq_mux::catalog::hang::Catalog, /// We need to store the codec information on the heap unfortunately. audio_codec: Vec, @@ -86,7 +86,7 @@ impl Consume { async fn run_catalog( callback: OnStatus, broadcast: moq_net::BroadcastConsumer, - mut catalog: moq_mux::catalog::hang::Consumer, + mut catalog: moq_mux::catalog::hang::Consumer, mut close: oneshot::Receiver<()>, ) -> Result<(), Error> { loop { diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index 1d6989a66..9b967024d 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -1,17 +1,24 @@ -use std::{str::FromStr, sync::Arc}; - -use bytes::Buf; +use moq_mux::catalog::hang::Extra; use moq_mux::import; use crate::{Error, Id, NonZeroSlab}; +/// A media importer fed whole chunks: either a single codec track or a container +/// that may publish several tracks. The format string picks which at creation. +enum Media { + // Boxed because the codec splitters/imports make this variant much larger + // than the (already boxed) container one. + Track(Box>), + Container(import::Container), +} + #[derive(Default)] pub struct Publish { /// Active broadcast producers for publishing. - broadcasts: NonZeroSlab<(moq_net::BroadcastProducer, moq_mux::catalog::Producer)>, + broadcasts: NonZeroSlab<(moq_net::BroadcastProducer, moq_mux::catalog::Producer)>, /// Active media encoders/decoders for publishing. - media: NonZeroSlab, + media: NonZeroSlab, /// Raw track producers (no media/container/catalog framing). tracks: NonZeroSlab, @@ -23,7 +30,8 @@ pub struct Publish { impl Publish { pub fn create(&mut self) -> Result { let mut broadcast = moq_net::Broadcast::new().produce(); - let catalog = moq_mux::catalog::Producer::new(&mut broadcast)?; + // The untyped `Extra` extension lets catalog sections be set by name across the FFI boundary. + let catalog = moq_mux::catalog::Producer::new_extra(&mut broadcast)?; let id = self.broadcasts.insert((broadcast, catalog))?; Ok(id) @@ -42,7 +50,7 @@ impl Publish { pub fn pair_mut( &mut self, id: Id, - ) -> Result<(&mut moq_net::BroadcastProducer, &mut moq_mux::catalog::Producer), Error> { + ) -> Result<(&mut moq_net::BroadcastProducer, &mut moq_mux::catalog::Producer), Error> { let (broadcast, catalog) = self.broadcasts.get_mut(id).ok_or(Error::BroadcastNotFound)?; Ok((broadcast, catalog)) } @@ -52,41 +60,48 @@ impl Publish { Ok(()) } - pub fn media_ordered(&mut self, broadcast: Id, format: &str, mut init: &[u8]) -> Result { + pub fn media_ordered(&mut self, broadcast: Id, format: &str, init: &[u8]) -> Result { let (broadcast, catalog) = self.broadcasts.get(broadcast).ok_or(Error::BroadcastNotFound)?; - let format = import::FramedFormat::from_str(format).map_err(|_| Error::UnknownFormat(format.to_string()))?; - let decoder = import::Framed::new(broadcast.clone(), catalog.clone(), format, &mut init) - .map_err(|err| Error::InitFailed(Arc::new(err)))?; - - let id = self.media.insert(decoder)?; + // A container may publish several tracks; a single codec fills one minted + // track. Try the container first so a codec format doesn't mint a stray + // track on the way to being recognized. + let media = match import::Container::new(broadcast.clone(), catalog.clone(), format, init) { + Ok(container) => Media::Container(container), + Err(moq_mux::Error::UnknownFormat(_)) => { + let mut broadcast = broadcast.clone(); + let name = broadcast.unique_name(&format!(".{format}")); + let track = broadcast.create_track(moq_net::Track::new(name))?; + match import::Track::new(track, catalog.clone(), format, init) { + Ok(track) => Media::Track(Box::new(track)), + Err(moq_mux::Error::UnknownFormat(_)) => return Err(Error::UnknownFormat(format.to_string())), + Err(err) => return Err(err.into()), + } + } + Err(err) => return Err(err.into()), + }; + + let id = self.media.insert(media)?; Ok(id) } - pub fn media_frame( - &mut self, - media: Id, - mut data: &[u8], - timestamp: hang::container::Timestamp, - ) -> Result<(), Error> { + pub fn media_frame(&mut self, media: Id, data: &[u8], timestamp: hang::container::Timestamp) -> Result<(), Error> { let media = self.media.get_mut(media).ok_or(Error::MediaNotFound)?; - media - .decode_frame(&mut data, Some(timestamp)) - .map_err(|err| Error::DecodeFailed(Arc::new(err)))?; - - if data.has_remaining() { - return Err(Error::DecodeFailed(Arc::new(anyhow::anyhow!( - "buffer was not fully consumed" - )))); + match media { + Media::Track(track) => track.decode(data, Some(timestamp))?, + Media::Container(container) => container.decode(data)?, } Ok(()) } pub fn media_close(&mut self, media: Id) -> Result<(), Error> { - let mut decoder = self.media.remove(media).ok_or(Error::MediaNotFound)?; - decoder.finish().map_err(|err| Error::DecodeFailed(Arc::new(err)))?; + let mut media = self.media.remove(media).ok_or(Error::MediaNotFound)?; + match &mut media { + Media::Track(track) => track.finish()?, + Media::Container(container) => container.finish()?, + } Ok(()) } diff --git a/rs/moq-audio/src/producer.rs b/rs/moq-audio/src/producer.rs index 2b59fd5c8..e4304728b 100644 --- a/rs/moq-audio/src/producer.rs +++ b/rs/moq-audio/src/producer.rs @@ -19,12 +19,12 @@ use crate::{AudioError, Frame}; /// The catalog rendition is registered at construction (not on first /// write), so a subscriber that opens the catalog before any frames /// arrive still sees the track. -pub struct AudioProducer { +pub struct AudioProducer { encoder: Encoder, resampler: Option, track: moq_mux::container::Producer, track_name: String, - catalog: moq_mux::catalog::Producer, + catalog: moq_mux::catalog::Producer, pending: Vec, /// Samples emitted since the current epoch (reset by [`reset_epoch`](Self::reset_epoch)). frames_produced: u64, @@ -34,12 +34,12 @@ pub struct AudioProducer { epoch_us: Option, } -impl AudioProducer { +impl AudioProducer { /// Build a producer for `name` on `broadcast`, registering the /// rendition in `catalog` immediately. pub fn new( broadcast: &mut moq_net::BroadcastProducer, - catalog: moq_mux::catalog::Producer, + catalog: moq_mux::catalog::Producer, name: impl Into, input: EncoderInput, output: EncoderOutput, @@ -155,6 +155,7 @@ impl AudioProducer { timestamp, payload, keyframe: true, + duration: None, }; self.track.write(mux_frame)?; self.track.finish_group()?; @@ -177,7 +178,7 @@ impl AudioProducer { } } -impl Drop for AudioProducer { +impl Drop for AudioProducer { fn drop(&mut self) { self.catalog.lock().audio.remove(&self.track_name); } diff --git a/rs/moq-boy/src/main.rs b/rs/moq-boy/src/main.rs index 0d59a423a..651d9af46 100644 --- a/rs/moq-boy/src/main.rs +++ b/rs/moq-boy/src/main.rs @@ -90,8 +90,8 @@ pub struct Config { /// track monitors, and async tasks. struct Session { video_encoder: video::VideoEncoder, - video_track: moq_net::TrackProducer, - audio_track: moq_net::TrackProducer, + video_track: moq_net::TrackDemand, + audio_track: moq_net::TrackDemand, /// Whether anyone is subscribed to the video/audio tracks. video_active: AtomicBool, @@ -109,7 +109,7 @@ struct Session { impl Session { /// Monitor a single track's subscription state. /// Sets the flag when a viewer subscribes, clears it when all unsubscribe. - async fn run_track_monitor(&self, name: &str, track: &moq_net::TrackProducer, flag: &AtomicBool) { + async fn run_track_monitor(&self, name: &str, track: &moq_net::TrackDemand, flag: &AtomicBool) { loop { if track.used().await.is_err() { break; @@ -243,8 +243,8 @@ async fn run(config: &Config) -> Result<()> { let audio_encoder = audio::AudioEncoder::new(broadcast.clone(), catalog.clone(), 44100)?; - let video_track = video_encoder.track.clone(); - let audio_track = audio_encoder.track().clone(); + let video_track = video_encoder.demand.clone(); + let audio_track = audio_encoder.track().demand(); let status_publisher = status::StatusPublisher::new(&mut broadcast)?; diff --git a/rs/moq-boy/src/video.rs b/rs/moq-boy/src/video.rs index 3eda1ddd2..104274aed 100644 --- a/rs/moq-boy/src/video.rs +++ b/rs/moq-boy/src/video.rs @@ -19,8 +19,8 @@ use crate::emulator::{HEIGHT, WIDTH}; /// Frames are submitted via `try_frame()` (non-blocking, drops if full). pub struct VideoEncoder { tx: tokio::sync::mpsc::Sender, - /// Clone of the video track producer, for monitoring used/unused. - pub track: moq_net::TrackProducer, + /// Watch-only handle to the video track, for monitoring used/unused. + pub demand: moq_net::TrackDemand, force_keyframe: Arc, /// Latest encode duration in microseconds. encode_duration: Arc, @@ -36,7 +36,7 @@ impl VideoEncoder { pub fn spawn(broadcast: moq_net::BroadcastProducer, catalog: moq_mux::catalog::Producer) -> Self { let (tx, rx) = tokio::sync::mpsc::channel(4); let producer = moq_video::encode::Producer::new(broadcast, catalog).expect("failed to create avc3 producer"); - let track = producer.track().expect("avc3 track is eagerly created").clone(); + let demand = producer.demand(); let force_keyframe = Arc::new(AtomicBool::new(false)); let encode_duration = Arc::new(AtomicU64::new(0)); @@ -49,7 +49,7 @@ impl VideoEncoder { Self { tx, - track, + demand, force_keyframe, encode_duration, _thread: thread, diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index 0c0cfd52d..cc2c0ba9e 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -1,6 +1,6 @@ use clap::Subcommand; use hang::moq_net; -use moq_mux::container::{flv, fmp4, hls, ts}; +use moq_mux::container::{flv, fmp4, ts}; #[derive(Subcommand, Clone)] pub enum PublishFormat { @@ -10,34 +10,40 @@ pub enum PublishFormat { Ts, /// FLV (Flash Video / RTMP) read from stdin. Flv, - // NOTE: No aac support because it needs framing. - Hls { - /// URL or file path of an HLS playlist to ingest. - #[arg(long)] - playlist: String, - }, } enum PublishDecoder { - Avc3(Box>), + Avc3(Box), Fmp4(Box), // TS carries undecoded elementary streams (SCTE-35, teletext, DVB AC-3, ...) // verbatim, so it uses the typed `mpegts` catalog extension rather than the untyped default. Ts(Box>), Flv(Box), - Hls(Box), } impl PublishDecoder { - /// Decode a chunk of bytes from stdin (Avc3, Fmp4, Ts, or Flv). - fn decode_buf(&mut self, buffer: &mut bytes::BytesMut) -> anyhow::Result<()> { + /// Decode a chunk of stdin bytes. Each importer buffers any partial trailing + /// frame internally, so the caller feeds fresh chunks rather than an + /// accumulating buffer. + fn decode_chunk(&mut self, chunk: &[u8]) -> anyhow::Result<()> { match self { - Self::Avc3(d) => d.decode_stream(buffer, None), - Self::Fmp4(d) => d.decode(buffer), - Self::Ts(d) => d.decode(buffer), - Self::Flv(d) => d.decode(buffer), - Self::Hls(_) => unreachable!(), + Self::Avc3(d) => d.decode(chunk)?, + Self::Fmp4(d) => d.decode(chunk)?, + Self::Ts(d) => d.decode(chunk)?, + Self::Flv(d) => d.decode(chunk)?, } + Ok(()) + } + + /// Flush any buffered trailing frame and close the tracks at end of input. + fn finish(&mut self) -> anyhow::Result<()> { + match self { + Self::Avc3(d) => d.finish()?, + Self::Fmp4(d) => d.finish()?, + Self::Ts(d) => d.finish()?, + Self::Flv(d) => d.finish()?, + } + Ok(()) } } @@ -69,8 +75,8 @@ impl Publish { let catalog = moq_mux::catalog::Producer::new(&mut broadcast)?; let source = match format { PublishFormat::Avc3 => { - let avc3 = moq_mux::codec::h264::Import::new(broadcast.clone(), catalog.clone()) - .with_mode(moq_mux::codec::h264::Mode::Avc3)?; + let track = moq_mux::import::unique_track(&mut broadcast, ".avc3")?; + let avc3 = moq_mux::import::TrackStream::new(track, catalog.clone(), "avc3")?; PublishDecoder::Avc3(Box::new(avc3)) } PublishFormat::Fmp4 => { @@ -82,10 +88,6 @@ impl Publish { let flv = flv::Import::new(broadcast.clone(), catalog.clone()); PublishDecoder::Flv(Box::new(flv)) } - PublishFormat::Hls { playlist } => { - let hls = hls::Import::new(broadcast.clone(), catalog.clone(), hls::Config::new(playlist.clone()))?; - PublishDecoder::Hls(Box::new(hls)) - } }; Ok(Self { source, broadcast }) @@ -96,23 +98,19 @@ impl Publish { } pub async fn run(self) -> anyhow::Result<()> { - match self.source { - PublishDecoder::Hls(mut decoder) => { - decoder.init().await?; - decoder.run().await - } - mut decoder => { - let mut stdin = tokio::io::stdin(); - let mut buffer = bytes::BytesMut::new(); + let mut decoder = self.source; + let mut stdin = tokio::io::stdin(); + let mut buffer = bytes::BytesMut::new(); - loop { - let n = tokio::io::AsyncReadExt::read_buf(&mut stdin, &mut buffer).await?; - if n == 0 { - return Ok(()); - } - decoder.decode_buf(&mut buffer)?; - } + loop { + buffer.clear(); + let n = tokio::io::AsyncReadExt::read_buf(&mut stdin, &mut buffer).await?; + if n == 0 { + // EOF: flush the importer's buffered trailing frame and close the tracks. + decoder.finish()?; + return Ok(()); } + decoder.decode_chunk(&buffer)?; } } } diff --git a/rs/moq-cli/src/subscribe.rs b/rs/moq-cli/src/subscribe.rs index 9152b1497..36a6e2dd3 100644 --- a/rs/moq-cli/src/subscribe.rs +++ b/rs/moq-cli/src/subscribe.rs @@ -98,10 +98,11 @@ impl Subscribe { async fn run_fmp4(self) -> anyhow::Result<()> { let mut stdout = tokio::io::stdout(); - // Fmp4 subscribes to the catalog internally, builds the merged init segment - // from the first catalog snapshot, then yields moof+mdat fragments in - // timestamp order across tracks. - let mut fmp4 = moq_mux::container::fmp4::Export::with_catalog_format(self.broadcast, self.catalog)? + // Fmp4 builds the merged init segment from the first catalog snapshot, then + // yields moof+mdat fragments in timestamp order across tracks. The catalog + // source honors the requested format (e.g. compressed `HangZ` or `Msf`). + let catalog = moq_mux::catalog::Consumer::<()>::new(&self.broadcast, self.catalog)?; + let mut fmp4 = moq_mux::container::fmp4::Export::new(self.broadcast, catalog) .with_latency(self.args.max_latency) .with_fragment_duration(self.args.fragment_duration); @@ -119,7 +120,8 @@ impl Subscribe { // Mkv writes EBML + an unknown-size Segment header, then per-fragment // Cluster elements. Avc3/Hev1 sources are transcoded to avc1/hvc1 // shape internally (synthesizing avcC/hvcC from inline parameter sets). - let mut mkv = moq_mux::container::mkv::Export::with_catalog_format(self.broadcast, self.catalog)? + let catalog = moq_mux::catalog::Consumer::<()>::new(&self.broadcast, self.catalog)?; + let mut mkv = moq_mux::container::mkv::Export::new(self.broadcast, catalog) .with_latency(self.args.max_latency) .with_fragment_duration(self.args.fragment_duration); diff --git a/rs/moq-ffi/src/audio.rs b/rs/moq-ffi/src/audio.rs index e786ea4de..cea1d5bf2 100644 --- a/rs/moq-ffi/src/audio.rs +++ b/rs/moq-ffi/src/audio.rs @@ -137,7 +137,7 @@ impl From for moq_audio::Frame { /// passed at publish time. #[derive(uniffi::Object)] pub struct MoqAudioProducer { - inner: std::sync::Mutex>, + inner: std::sync::Mutex>>, } #[uniffi::export] diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 93cb4a3a2..502114d5e 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -29,7 +29,7 @@ pub struct MoqCatalogConsumer { } struct Catalog { - inner: moq_mux::catalog::hang::Consumer, + inner: moq_mux::catalog::hang::Consumer, } impl Catalog { @@ -84,7 +84,7 @@ impl MoqBroadcastConsumer { pub fn subscribe_catalog(&self) -> Result, MoqError> { let _guard = crate::ffi::RUNTIME.enter(); let track = self.inner.subscribe_track(&hang::catalog::Catalog::default_track())?; - let consumer = moq_mux::catalog::hang::Consumer::from(track); + let consumer = moq_mux::catalog::hang::Consumer::::from(track); Ok(Arc::new(MoqCatalogConsumer { task: Task::new(Catalog { inner: consumer }), })) diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index d2307ba64..424695dac 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -81,7 +81,7 @@ pub struct MoqFrame { pub keyframe: bool, } -pub(crate) fn convert_catalog(catalog: &moq_mux::catalog::hang::Catalog) -> MoqCatalog { +pub(crate) fn convert_catalog(catalog: &moq_mux::catalog::hang::Catalog) -> MoqCatalog { let video = catalog .video .renditions diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 02a9c49b2..3dd1d3918 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -1,7 +1,6 @@ -use std::str::FromStr; use std::sync::Arc; -use bytes::Buf; +use moq_mux::catalog::hang::Extra; use crate::consumer::{MoqBroadcastConsumer, MoqGroupConsumer, MoqTrackConsumer}; use crate::error::MoqError; @@ -11,19 +10,68 @@ use crate::ffi::Task; pub(crate) struct BroadcastProducer { pub(crate) broadcast: moq_net::BroadcastProducer, - pub(crate) catalog: moq_mux::catalog::Producer, + pub(crate) catalog: moq_mux::catalog::Producer, +} + +/// A whole-frame importer: a single codec track, or a container that may publish +/// several tracks. The format string picks which when the producer is created. +enum MediaDecoder { + // Boxed because the codec splitters/imports make this variant much larger + // than the (already boxed) container one. + Track(Box>), + Container(moq_mux::import::Container), +} + +impl MediaDecoder { + fn decode(&mut self, frame: &[u8], pts: Option) -> moq_mux::Result<()> { + match self { + Self::Track(t) => t.decode(frame, pts), + Self::Container(c) => c.decode(frame), + } + } + + fn finish(&mut self) -> moq_mux::Result<()> { + match self { + Self::Track(t) => t.finish(), + Self::Container(c) => c.finish(), + } + } } struct MediaProducer { - decoder: moq_mux::import::Framed, - track: moq_net::TrackProducer, + decoder: MediaDecoder, + /// `Some` for a single codec track, whose subscriber demand (name/used/unused) + /// is observable; `None` for a container that may publish several tracks. + demand: Option, +} + +/// A byte-stream importer: a single codec track or a container that may publish +/// several tracks. The format string picks which when the producer is created. +enum StreamDecoder { + Track(Box>), + Container(moq_mux::import::ContainerStream), +} + +impl StreamDecoder { + fn decode(&mut self, data: &[u8]) -> moq_mux::Result<()> { + match self { + Self::Track(t) => t.decode(data), + Self::Container(c) => c.decode(data), + } + } + + fn finish(&mut self) -> moq_mux::Result<()> { + match self { + Self::Track(t) => t.finish(), + Self::Container(c) => c.finish(), + } + } } struct MediaStreamProducer { - decoder: moq_mux::import::Stream, - // Carries the partial trailing frame between `write` calls; `decode_stream` - // consumes whole frames and leaves the remainder here. - buffer: bytes::BytesMut, + // The importer buffers any partial trailing frame internally, so callers can + // write arbitrary chunks without retaining a remainder here. + decoder: StreamDecoder, } #[derive(uniffi::Object)] @@ -109,7 +157,8 @@ impl MoqBroadcastProducer { pub fn new() -> Result, MoqError> { let _guard = crate::ffi::RUNTIME.enter(); let mut broadcast = moq_net::Broadcast::new().produce(); - let catalog = moq_mux::catalog::Producer::new(&mut broadcast)?; + // The untyped `Extra` extension lets catalog sections be set by name across the FFI boundary. + let catalog = moq_mux::catalog::Producer::new_extra(&mut broadcast)?; Ok(Arc::new(Self { state: std::sync::Mutex::new(Some(BroadcastProducer { broadcast, catalog })), })) @@ -122,24 +171,32 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::RUNTIME.enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or_else(|| MoqError::Closed)?; - let format = moq_mux::import::FramedFormat::from_str(&format) - .map_err(|_| MoqError::Codec(format!("unknown format: {format}")))?; - - let mut buf = init.as_slice(); - let decoder = moq_mux::import::Framed::new(state.broadcast.clone(), state.catalog.clone(), format, &mut buf) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; - - if buf.has_remaining() { - return Err(MoqError::Codec("init failed: trailing bytes".into())); - } - - let track = decoder - .track() - .map_err(|err| MoqError::Codec(format!("track unavailable: {err}")))? - .clone(); + // A container may publish several tracks; a single codec fills one minted + // track. Try the container first so a codec format doesn't mint a stray + // track on the way to being recognized. + let (decoder, demand) = + match moq_mux::import::Container::new(state.broadcast.clone(), state.catalog.clone(), &format, &init) { + Ok(container) => (MediaDecoder::Container(container), None), + Err(moq_mux::Error::UnknownFormat(_)) => { + let mut broadcast = state.broadcast.clone(); + let name = broadcast.unique_name(&format!(".{format}")); + let track = broadcast.create_track(moq_net::Track::new(name))?; + match moq_mux::import::Track::new(track, state.catalog.clone(), &format, &init) { + Ok(import) => { + let demand = import.demand(); + (MediaDecoder::Track(Box::new(import)), Some(demand)) + } + Err(moq_mux::Error::UnknownFormat(_)) => { + return Err(MoqError::Codec(format!("unknown format: {format}"))); + } + Err(err) => return Err(MoqError::Codec(format!("init failed: {err}"))), + } + } + Err(err) => return Err(MoqError::Codec(format!("init failed: {err}"))), + }; Ok(Arc::new(MoqMediaProducer { - inner: std::sync::Mutex::new(Some(MediaProducer { decoder, track })), + inner: std::sync::Mutex::new(Some(MediaProducer { decoder, demand })), })) } @@ -157,30 +214,22 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::RUNTIME.enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or_else(|| MoqError::Closed)?; - let format = moq_mux::import::FramedFormat::from_str(&format) - .map_err(|_| MoqError::Codec(format!("unknown format: {format}")))?; - let track_clone = { let guard = track.inner.lock().unwrap(); guard.as_ref().ok_or_else(|| MoqError::Closed)?.clone() }; - let mut buf = init.as_slice(); - let decoder = - moq_mux::import::Framed::new_with_track(track_clone.clone(), state.catalog.clone(), format, &mut buf) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; - - if buf.has_remaining() { - return Err(MoqError::Codec("init failed: trailing bytes".into())); - } + let import = moq_mux::import::Track::new(track_clone, state.catalog.clone(), &format, &init) + .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; + let demand = import.demand(); let mut guard = track.inner.lock().unwrap(); guard.take().ok_or_else(|| MoqError::Closed)?; Ok(Arc::new(MoqMediaProducer { inner: std::sync::Mutex::new(Some(MediaProducer { - decoder, - track: track_clone, + decoder: MediaDecoder::Track(Box::new(import)), + demand: Some(demand), })), })) } @@ -195,17 +244,29 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::RUNTIME.enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or_else(|| MoqError::Closed)?; - let format = moq_mux::import::StreamFormat::from_str(&format) - .map_err(|_| MoqError::Codec(format!("unknown stream format: {format}")))?; - - let decoder = moq_mux::import::Stream::new(state.broadcast.clone(), state.catalog.clone(), format) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; + // A container stream may publish several tracks; a single codec fills one + // minted track. Try the container first so a codec format doesn't mint a + // stray track on the way to being recognized. + let decoder = + match moq_mux::import::ContainerStream::new(state.broadcast.clone(), state.catalog.clone(), &format) { + Ok(container) => StreamDecoder::Container(container), + Err(moq_mux::Error::UnknownFormat(_)) => { + let mut broadcast = state.broadcast.clone(); + let name = broadcast.unique_name(&format!(".{format}")); + let track = broadcast.create_track(moq_net::Track::new(name))?; + match moq_mux::import::TrackStream::new(track, state.catalog.clone(), &format) { + Ok(import) => StreamDecoder::Track(Box::new(import)), + Err(moq_mux::Error::UnknownFormat(_)) => { + return Err(MoqError::Codec(format!("unknown stream format: {format}"))); + } + Err(err) => return Err(MoqError::Codec(format!("init failed: {err}"))), + } + } + Err(err) => return Err(MoqError::Codec(format!("init failed: {err}"))), + }; Ok(Arc::new(MoqMediaStreamProducer { - inner: std::sync::Mutex::new(Some(MediaStreamProducer { - decoder, - buffer: bytes::BytesMut::new(), - })), + inner: std::sync::Mutex::new(Some(MediaStreamProducer { decoder })), })) } @@ -417,24 +478,33 @@ impl MoqGroupProducer { #[uniffi::export] impl MoqMediaProducer { /// Return the name of the media track. + /// + /// Errors for a multi-track container source, which has no single track name. pub fn name(&self) -> Result { let _guard = crate::ffi::RUNTIME.enter(); let guard = self.inner.lock().unwrap(); let media = guard.as_ref().ok_or_else(|| MoqError::Closed)?; - Ok(media.track.name.clone()) + let demand = media + .demand + .as_ref() + .ok_or_else(|| MoqError::Codec("track name unavailable for a multi-track container".into()))?; + Ok(demand.name().to_string()) } /// Wait until this media track has at least one active consumer. + /// + /// Errors for a multi-track container source, which has no single demand. pub async fn used(&self) -> Result<(), MoqError> { - let track = self + let demand = self .inner .lock() .unwrap() .as_ref() .ok_or(MoqError::Closed)? - .track - .clone(); - match crate::ffi::RUNTIME.spawn(async move { track.used().await }).await { + .demand + .clone() + .ok_or_else(|| MoqError::Codec("demand unavailable for a multi-track container".into()))?; + match crate::ffi::RUNTIME.spawn(async move { demand.used().await }).await { Ok(result) => result.map_err(Into::into), Err(e) if e.is_cancelled() => Err(MoqError::Cancelled), Err(e) => Err(MoqError::Task(e)), @@ -442,16 +512,19 @@ impl MoqMediaProducer { } /// Wait until this media track has no active consumers. + /// + /// Errors for a multi-track container source, which has no single demand. pub async fn unused(&self) -> Result<(), MoqError> { - let track = self + let demand = self .inner .lock() .unwrap() .as_ref() .ok_or(MoqError::Closed)? - .track - .clone(); - match crate::ffi::RUNTIME.spawn(async move { track.unused().await }).await { + .demand + .clone() + .ok_or_else(|| MoqError::Codec("demand unavailable for a multi-track container".into()))?; + match crate::ffi::RUNTIME.spawn(async move { demand.unused().await }).await { Ok(result) => result.map_err(Into::into), Err(e) if e.is_cancelled() => Err(MoqError::Cancelled), Err(e) => Err(MoqError::Task(e)), @@ -467,16 +540,11 @@ impl MoqMediaProducer { let media = guard.as_mut().ok_or_else(|| MoqError::Closed)?; let timestamp = hang::container::Timestamp::from_micros(timestamp_us)?; - let mut data = payload.as_slice(); media .decoder - .decode_frame(&mut data, Some(timestamp)) + .decode(payload.as_slice(), Some(timestamp)) .map_err(|err| MoqError::Codec(format!("decode failed: {err}")))?; - if data.has_remaining() { - return Err(MoqError::Codec("buffer was not fully consumed".into())); - } - Ok(()) } @@ -503,10 +571,9 @@ impl MoqMediaStreamProducer { let mut guard = self.inner.lock().unwrap(); let media = guard.as_mut().ok_or_else(|| MoqError::Closed)?; - media.buffer.extend_from_slice(&payload); media .decoder - .decode_stream(&mut media.buffer) + .decode(&payload) .map_err(|err| MoqError::Codec(format!("decode failed: {err}")))?; Ok(()) } diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index b19e351ae..e4dad6722 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result, ensure}; use bytes::Bytes; use hang::moq_net; -use moq_mux::import::{Framed, FramedFormat}; +use moq_mux::import; use super::session::CAT; use super::timeline::{SegmentInfo, classify_segment, frame_micros}; @@ -27,7 +27,7 @@ enum PadState { /// One sink pad's media producer plus its timeline policy. pub struct Pad { - framed: Option, + framed: Option, caps: Option, /// Set once a producer build rejects this pad's caps or bitstream; further buffers are dropped and /// the track stays finalized. Isolated to the pad, so the session and other pads keep going. @@ -87,40 +87,49 @@ impl Pad { let structure = caps.structure(0).context("empty caps")?; // Renegotiation: finalize the previous producer before replacing it (closed once, not abandoned). self.finalize()?; - let broadcast = broadcast.clone(); + let mut broadcast = broadcast.clone(); let catalog = catalog.clone(); - // Every codec converges on one Framed; only the caps -> producer construction differs. The pad - // template fixes the structural fields (h264/h265 byte-stream/au, AAC mpegversion=4/stream-format=raw), - // so negotiation rejects non-conforming caps before they reach here; only fields the template can't + + // Mint a track for a single codec and hand it to the importer. Every codec converges on one + // `import::Track`; only the caps -> (format, init) construction differs. The pad template fixes + // the structural fields (h264/h265 byte-stream/au, AAC mpegversion=4/stream-format=raw), so + // negotiation rejects non-conforming caps before they reach here; only fields the template can't // pin (the AAC codec_data) are checked below. - let framed: Framed = match structure.name().as_str() { - "video/x-h264" => Framed::new(broadcast, catalog, FramedFormat::Avc3, &mut Bytes::new())?, - "video/x-h265" => Framed::new(broadcast, catalog, FramedFormat::Hev1, &mut Bytes::new())?, - "video/x-av1" => Framed::new(broadcast, catalog, FramedFormat::Av01, &mut Bytes::new())?, - "video/x-vp8" => Framed::new(broadcast, catalog, FramedFormat::Vp8, &mut Bytes::new())?, - "video/x-vp9" => Framed::new(broadcast, catalog, FramedFormat::Vp9, &mut Bytes::new())?, + let mut make = |format: &str, suffix: &str, init: &[u8]| -> Result { + let track = import::unique_track(&mut broadcast, suffix)?; + Ok(import::Track::new(track, catalog.clone(), format, init)?) + }; + + let framed: import::Track = match structure.name().as_str() { + "video/x-h264" => make("avc3", ".avc3", &[])?, + "video/x-h265" => make("hev1", ".hev1", &[])?, + "video/x-av1" => make("av01", ".av01", &[])?, + "video/x-vp8" => make("vp8", ".vp8", &[])?, + "video/x-vp9" => make("vp9", ".vp9", &[])?, "audio/mpeg" => { // AAC: the AudioSpecificConfig rides in caps as codec_data, not in the bitstream. let codec_data = structure .get::("codec_data") .context("AAC caps missing codec_data")?; let map = codec_data.map_readable().context("failed to map AAC codec_data")?; - let mut data = Bytes::copy_from_slice(map.as_slice()); - Framed::new(broadcast, catalog, FramedFormat::Aac, &mut data)? + make("aac", ".aac", map.as_slice())? } "audio/x-opus" => { // Opus: GStreamer carries channels/rate in caps (not an OpusHead), and valid Opus caps // always include them. Require them rather than guessing a stereo/48k default that could - // misadvertise the stream. + // misadvertise the stream. Synthesize the OpusHead the importer parses for config. let channels: i32 = structure.get("channels").context("Opus caps missing channels")?; let rate: i32 = structure.get("rate").context("Opus caps missing rate")?; ensure!(channels > 0, "Opus caps has non-positive channel count {channels}"); + // `opus_head` emits channel-mapping family 0, which only describes mono/stereo. + // Reject more until a multistream OpusHead is synthesized. + ensure!( + channels <= 2, + "multichannel Opus is not supported yet (channels={channels})" + ); ensure!(rate > 0, "Opus caps has non-positive sample rate {rate}"); - let config = moq_mux::codec::opus::Config { - sample_rate: rate as u32, - channel_count: channels as u32, - }; - moq_mux::codec::opus::Import::new(broadcast, catalog, config)?.into() + let head = opus_head(channels as u8, rate as u32); + make("opus", ".opus", &head)? } other => anyhow::bail!("unsupported caps: {other}"), }; @@ -205,7 +214,7 @@ impl Pad { /// pad. /// Returns `true` the first time a buffer is dropped because the pad has no TIME segment, so the /// caller can surface it once on the bus: without a timeline the pad can never publish. - pub fn push_buffer(&mut self, mut data: Bytes, pts: Option) -> bool { + pub fn push_buffer(&mut self, data: Bytes, pts: Option) -> bool { if self.failed { return false; } @@ -218,7 +227,7 @@ impl Pad { Ok(micros) => { let ts = hang::container::Timestamp::from_micros(micros).ok(); let framed = self.framed.as_mut().expect("framed present"); - if let Err(err) = framed.decode_frame(&mut data, ts) { + if let Err(err) = framed.decode(&data, ts) { gst::warning!(CAT, "invalidating pad: {err}"); self.fail(); } @@ -241,16 +250,27 @@ impl Pad { let Some(mut framed) = self.framed.take() else { return Ok(false); }; - // A lazy codec (H.265/AV1/VP8/VP9) given CAPS but no frame never created its track, so there is - // nothing to flush and finish() would error "not initialized". track() is Ok only once a track - // exists; a real finish error on an initialized one still surfaces. - if framed.track().is_ok() { - framed.finish()?; - } + // The track is minted up front, so finish() always has a producer to close (it's a no-op for a + // codec that received CAPS but never a frame, so its catalog rendition was never registered). + framed.finish()?; Ok(true) } } +/// Synthesize the 19-byte OpusHead the importer parses for codec config, since GStreamer Opus caps +/// carry channels/rate as fields rather than an OpusHead blob. +fn opus_head(channels: u8, sample_rate: u32) -> Vec { + let mut head = Vec::with_capacity(19); + head.extend_from_slice(b"OpusHead"); + head.push(1); // version + head.push(channels); + head.extend_from_slice(&0u16.to_le_bytes()); // pre-skip + head.extend_from_slice(&sample_rate.to_le_bytes()); + head.extend_from_slice(&0u16.to_le_bytes()); // output gain + head.push(0); // channel mapping family + head +} + /// Media types moqsink can build a producer for. Checked synchronously at the CAPS event so an /// unsupported type is rejected with NotNegotiated. The structural fields (byte-stream/au, AAC /// mpegversion/stream-format) are pinned by the pad template, so negotiation enforces them. diff --git a/rs/moq-msf/CHANGELOG.md b/rs/moq-msf/CHANGELOG.md index 4c18e88ca..935098c05 100644 --- a/rs/moq-msf/CHANGELOG.md +++ b/rs/moq-msf/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Track draft-ietf-moq-msf-01, with the wire format hidden behind the API. `Catalog` is now a version-agnostic snapshot (`{ tracks }`); the `version` field and the `Version` enum are gone. Parsing accepts draft-00 (numeric `version`, inline `initData`) and draft-01 (string `version`, root `initDataList` + per-track `initRef`); serializing always emits draft-01. Init data is resolved to inline `Track::init_data` on parse and hoisted into a deduplicated `initDataList` on serialize, so callers never touch the version or the init-data indirection. + ## [0.2.0](https://github.com/moq-dev/moq/compare/moq-msf-v0.1.3...moq-msf-v0.2.0) - 2026-05-23 ### Added diff --git a/rs/moq-msf/README.md b/rs/moq-msf/README.md index f796ae802..dbb7fb71f 100644 --- a/rs/moq-msf/README.md +++ b/rs/moq-msf/README.md @@ -10,9 +10,11 @@ Catalog types for the MOQT Streaming Format (MSF). -This crate implements the catalog format defined in [draft-ietf-moq-msf-00](https://www.ietf.org/archive/id/draft-ietf-moq-msf-00.txt), +This crate implements the catalog format defined in [draft-ietf-moq-msf-01](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.txt), with additional support for CMAF packaging from [draft-ietf-moq-cmsf-00](https://www.ietf.org/archive/id/draft-ietf-moq-cmsf-00.txt). +`Catalog` is a version-agnostic snapshot of tracks: the wire details (the catalog `version` and draft-01's `initDataList`/`initRef` indirection for init data) are handled during (de)serialization. Parsing accepts both draft-00 and draft-01 catalogs and serializing always emits the newest draft, so older publishers remain compatible and callers never touch the version on the wire. + Used by [moq-mux](https://github.com/moq-dev/moq/tree/main/rs/moq-mux) for muxing/demuxing media. For the higher-level [hang](https://github.com/moq-dev/moq/tree/main/rs/hang) catalog format used elsewhere in this repo, see that crate. See the [API documentation](https://docs.rs/moq-msf/) for details. diff --git a/rs/moq-msf/src/lib.rs b/rs/moq-msf/src/lib.rs index 196bc6952..473ab1201 100644 --- a/rs/moq-msf/src/lib.rs +++ b/rs/moq-msf/src/lib.rs @@ -1,30 +1,41 @@ //! MSF (MOQT Streaming Format) catalog types. //! //! This crate provides types for the MSF catalog format as defined in -//! draft-ietf-moq-msf-00, with additional support for CMAF packaging +//! draft-ietf-moq-msf-01, with additional support for CMAF packaging //! from draft-ietf-moq-cmsf-00. //! +//! [`Catalog`] is a version-agnostic snapshot of tracks. The wire details are +//! hidden behind (de)serialization: parsing accepts both draft-00 (numeric +//! `version`, inline `initData`) and draft-01 (string `version`, with init data +//! held in a root `initDataList` and referenced per-track by `initRef`). +//! Serializing always emits the newest draft, and init data is resolved to +//! inline [`Track::init_data`] either way, so callers never touch the version +//! or the init-data indirection. +//! //! References: -//! - +//! - //! - use std::fmt; use std::str::FromStr; +use std::time::Duration; use serde::{Deserialize, Serialize}; +use serde_with::DurationMilliSeconds; /// The default track name for the MSF catalog. pub const DEFAULT_NAME: &str = "catalog"; -/// Root MSF catalog object. -#[serde_with::skip_serializing_none] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] +/// A snapshot of an MSF catalog: the tracks currently in a broadcast. +/// +/// This is a version-agnostic view. The on-wire details (the catalog `version` +/// field, and draft-01's `initDataList`/`initRef` indirection for initialization +/// data) are handled during (de)serialization, so callers only ever see +/// resolved tracks with inline [`Track::init_data`]. Parsing accepts both +/// draft-00 and draft-01 catalogs; serializing always emits the newest draft. +#[derive(Debug, Clone, PartialEq, Default)] pub struct Catalog { - /// MSF version. Always 1 for this draft. - pub version: u32, - - /// Array of track descriptions. + /// The tracks in this catalog snapshot. pub tracks: Vec, } @@ -35,6 +46,7 @@ pub struct Catalog { /// then assign whichever optional fields they need; struct-literal /// construction (with or without `..base`) is not available outside this /// crate. +#[serde_with::serde_as] #[serde_with::skip_serializing_none] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] @@ -47,6 +59,11 @@ pub struct Track { pub packaging: Packaging, /// Whether new objects will be appended. + /// + /// draft-00 marks this required, but its own examples omit it on + /// `mediatimeline`/`eventtimeline` tracks, so we default to `false` when + /// absent rather than reject the whole catalog. + #[serde(default)] pub is_live: bool, /// Content role. @@ -73,9 +90,18 @@ pub struct Track { /// Bitrate in bits per second. pub bitrate: Option, - /// Base64-encoded initialization data. + /// Resolved base64 initialization data. + /// + /// On the wire this is carried indirectly through draft-01's `initDataList` + + /// `initRef`; [`Catalog`] (de)serialization resolves it so callers always see + /// the inline payload here. draft-00's inline `initData` is also accepted. pub init_data: Option, + /// Wire-only pointer into the catalog's `initDataList` (draft-01). Populated + /// only while (de)serializing; resolved into `init_data` on parse and never + /// surfaced to callers. + init_ref: Option, + /// Render group for synchronized playback. pub render_group: Option, @@ -94,8 +120,12 @@ pub struct Track { #[serde(rename = "maxObjSapStartingType")] pub max_obj_sap_starting_type: Option, - /// Jitter in milliseconds (non-standard extension, matches JS implementation). - pub jitter: Option, + /// Jitter (non-standard extension; not in the MSF/CMSF drafts). + /// + /// Serialized as a JSON integer number of milliseconds, matching the hang + /// catalog. Sub-ms precision isn't meaningful for jitter. + #[serde_as(as = "Option>")] + pub jitter: Option, } impl Catalog { @@ -111,6 +141,166 @@ impl Catalog { } } +/// The newest MSF draft string this crate emits. +const CURRENT_VERSION: &str = "draft-01"; + +impl Serialize for Catalog { + fn serialize(&self, serializer: S) -> Result { + use std::collections::HashMap; + + // Hoist inline init payloads into a shared, deduplicated initDataList and + // point each track at its entry via initRef. That's the draft-01 wire + // shape; identical payloads across tracks collapse to one entry. + let mut init_data_list: Vec = Vec::new(); + let mut ids: HashMap = HashMap::new(); + let mut tracks = Vec::with_capacity(self.tracks.len()); + + for track in &self.tracks { + let mut track = track.clone(); + if let Some(payload) = track.init_data.take() { + let id = if let Some(id) = ids.get(&payload) { + id.clone() + } else { + let id = format!("init{}", init_data_list.len()); + init_data_list.push(InitData { + id: id.clone(), + kind: "inline".to_string(), + data: payload.clone(), + }); + ids.insert(payload, id.clone()); + id + }; + track.init_ref = Some(id); + } + tracks.push(track); + } + + Wire { + version: WireVersion, + tracks, + init_data_list: (!init_data_list.is_empty()).then_some(init_data_list), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Catalog { + fn deserialize>(deserializer: D) -> Result { + use std::collections::HashMap; + + let wire = Wire::deserialize(deserializer)?; + let init_data_list = wire.init_data_list.unwrap_or_default(); + + // id -> inline payload, built once so resolution is linear in the number + // of tracks rather than tracks x entries. + let inline: HashMap<&str, &str> = init_data_list + .iter() + .filter(|e| e.kind == "inline") + .map(|e| (e.id.as_str(), e.data.as_str())) + .collect(); + + let tracks = wire + .tracks + .into_iter() + .map(|mut track| { + // Resolve draft-01 initRef into inline init_data so callers never + // see the indirection. Inline init_data (draft-00) is kept as-is. + if track.init_data.is_none() { + if let Some(id) = track.init_ref.take() { + track.init_data = inline.get(id.as_str()).map(|data| data.to_string()); + } + } + track.init_ref = None; + track + }) + .collect(); + + Ok(Catalog { tracks }) + } +} + +/// The on-wire catalog shape, carrying the bits [`Catalog`] hides from callers. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Wire { + version: WireVersion, + #[serde(default)] + tracks: Vec, + init_data_list: Option>, +} + +/// Wire encoding of the catalog version. Deserialization accepts draft-00's +/// number `1` or any draft-01 `"draft-XX"` string; serialization always emits +/// [`CURRENT_VERSION`], so callers never deal with the version on the wire. +struct WireVersion; + +impl Serialize for WireVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(CURRENT_VERSION) + } +} + +impl<'de> Deserialize<'de> for WireVersion { + fn deserialize>(deserializer: D) -> Result { + struct VersionVisitor; + + impl serde::de::Visitor<'_> for VersionVisitor { + type Value = WireVersion; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("the JSON number 1 (draft-00) or a \"draft-XX\" version string") + } + + // draft-00's only defined numeric version is 1. Accept it from any JSON + // number type (serde_json picks u64/i64/f64 by shape, and `1.0` is a + // valid spelling), and reject everything else. + fn visit_u64(self, v: u64) -> Result { + match v { + 1 => Ok(WireVersion), + other => Err(E::custom(format!("unsupported MSF catalog version: {other}"))), + } + } + + fn visit_i64(self, v: i64) -> Result { + if v == 1 { + Ok(WireVersion) + } else { + Err(E::custom(format!("unsupported MSF catalog version: {v}"))) + } + } + + fn visit_f64(self, v: f64) -> Result { + if v == 1.0 { + Ok(WireVersion) + } else { + Err(E::custom(format!("unsupported MSF catalog version: {v}"))) + } + } + + fn visit_str(self, _v: &str) -> Result { + // Any draft string is accepted; we always re-emit the current draft. + Ok(WireVersion) + } + } + + deserializer.deserialize_any(VersionVisitor) + } +} + +/// An entry in the wire `initDataList`, referenced by a track's `initRef`. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct InitData { + /// Identifier, unique within the catalog, that a track's `initRef` points at. + id: String, + /// Reference type. draft-01 defines only `"inline"` (base64 payload in `data`). + #[serde(rename = "type")] + kind: String, + /// The init payload, interpreted per `kind`. For `"inline"`, base64. + data: String, +} + impl Track { /// Construct a track with the required identity fields set and every /// optional field cleared. Fields are `pub`, so callers set whatever they @@ -132,6 +322,7 @@ impl Track { channel_config: None, bitrate: None, init_data: None, + init_ref: None, render_group: None, alt_group: None, max_grp_sap_starting_type: None, @@ -267,29 +458,79 @@ impl<'de> Deserialize<'de> for Role { mod test { use super::*; + fn video_track() -> Track { + Track { + name: "video0".to_string(), + packaging: Packaging::Legacy, + is_live: true, + role: Some(Role::Video), + codec: Some("avc3.64001f".to_string()), + width: Some(1280), + height: Some(720), + framerate: Some(30.0), + samplerate: None, + channel_config: None, + bitrate: Some(6_000_000), + init_data: None, + init_ref: None, + render_group: Some(1), + alt_group: None, + max_grp_sap_starting_type: None, + max_obj_sap_starting_type: None, + jitter: None, + } + } + + fn audio_track() -> Track { + Track { + name: "audio0".to_string(), + packaging: Packaging::Legacy, + is_live: true, + role: Some(Role::Audio), + codec: Some("opus".to_string()), + width: None, + height: None, + framerate: None, + samplerate: Some(48_000), + channel_config: Some("2".to_string()), + bitrate: Some(128_000), + init_data: None, + init_ref: None, + render_group: Some(1), + alt_group: None, + max_grp_sap_starting_type: None, + max_obj_sap_starting_type: None, + jitter: None, + } + } + + fn track_with_sap_and_jitter() -> Track { + Track { + name: "video0".to_string(), + packaging: Packaging::Cmaf, + is_live: true, + role: Some(Role::Video), + codec: Some("avc1.640028".to_string()), + width: Some(1920), + height: Some(1080), + framerate: Some(30.0), + samplerate: None, + channel_config: None, + bitrate: Some(5_000_000), + init_data: None, + init_ref: None, + render_group: Some(1), + alt_group: None, + max_grp_sap_starting_type: Some(1), + max_obj_sap_starting_type: Some(2), + jitter: Some(Duration::from_millis(15)), + } + } + #[test] fn serialize_video_track() { let catalog = Catalog { - version: 1, - tracks: vec![Track { - name: "video0".to_string(), - packaging: Packaging::Legacy, - is_live: true, - role: Some(Role::Video), - codec: Some("avc3.64001f".to_string()), - width: Some(1280), - height: Some(720), - framerate: Some(30.0), - samplerate: None, - channel_config: None, - bitrate: Some(6_000_000), - init_data: None, - render_group: Some(1), - alt_group: None, - max_grp_sap_starting_type: None, - max_obj_sap_starting_type: None, - jitter: None, - }], + tracks: vec![video_track()], }; let json = catalog.to_string().unwrap(); @@ -311,26 +552,7 @@ mod test { #[test] fn serialize_audio_track() { let catalog = Catalog { - version: 1, - tracks: vec![Track { - name: "audio0".to_string(), - packaging: Packaging::Legacy, - is_live: true, - role: Some(Role::Audio), - codec: Some("opus".to_string()), - width: None, - height: None, - framerate: None, - samplerate: Some(48_000), - channel_config: Some("2".to_string()), - bitrate: Some(128_000), - init_data: None, - render_group: Some(1), - alt_group: None, - max_grp_sap_starting_type: None, - max_obj_sap_starting_type: None, - jitter: None, - }], + tracks: vec![audio_track()], }; let json = catalog.to_string().unwrap(); @@ -380,10 +602,7 @@ mod test { #[test] fn roundtrip_empty() { - let catalog = Catalog { - version: 1, - tracks: vec![], - }; + let catalog = Catalog { tracks: vec![] }; let json = catalog.to_string().unwrap(); let parsed = Catalog::from_str(&json).unwrap(); assert_eq!(catalog, parsed); @@ -391,61 +610,26 @@ mod test { #[test] fn cmaf_packaging() { - let catalog = Catalog { - version: 1, - tracks: vec![Track { - name: "hd".to_string(), - packaging: Packaging::Cmaf, - is_live: true, - role: Some(Role::Video), - codec: Some("avc1.640028".to_string()), - width: Some(1920), - height: Some(1080), - framerate: Some(30.0), - samplerate: None, - channel_config: None, - bitrate: Some(5_000_000), - init_data: Some("AQID".to_string()), - render_group: Some(1), - alt_group: Some(1), - max_grp_sap_starting_type: None, - max_obj_sap_starting_type: None, - jitter: None, - }], - }; + let mut track = track_with_sap_and_jitter(); + track.name = "hd".to_string(); + track.alt_group = Some(1); + track.max_grp_sap_starting_type = None; + track.max_obj_sap_starting_type = None; + track.jitter = None; + track.init_data = Some("AQID".to_string()); + + let catalog = Catalog { tracks: vec![track] }; let json = catalog.to_string().unwrap(); assert!(json.contains("\"packaging\":\"cmaf\"")); let parsed = Catalog::from_str(&json).unwrap(); assert_eq!(catalog, parsed); - } - - fn track_with_sap_and_jitter() -> Track { - Track { - name: "video0".to_string(), - packaging: Packaging::Cmaf, - is_live: true, - role: Some(Role::Video), - codec: Some("avc1.640028".to_string()), - width: Some(1920), - height: Some(1080), - framerate: Some(30.0), - samplerate: None, - channel_config: None, - bitrate: Some(5_000_000), - init_data: None, - render_group: Some(1), - alt_group: None, - max_grp_sap_starting_type: Some(1), - max_obj_sap_starting_type: Some(2), - jitter: Some(15.5), - } + assert_eq!(parsed.tracks[0].init_data.as_deref(), Some("AQID")); } #[test] fn serialize_sap_fields() { let catalog = Catalog { - version: 1, tracks: vec![track_with_sap_and_jitter()], }; @@ -457,7 +641,7 @@ mod test { let track = &value["tracks"][0]; assert_eq!(track.get("maxGrpSapStartingType"), Some(&serde_json::json!(1))); assert_eq!(track.get("maxObjSapStartingType"), Some(&serde_json::json!(2))); - assert_eq!(track.get("jitter"), Some(&serde_json::json!(15.5))); + assert_eq!(track.get("jitter"), Some(&serde_json::json!(15))); // Snake-case names must NOT appear on the wire. assert!(track.get("max_grp_sap_starting_type").is_none()); @@ -494,7 +678,6 @@ mod test { #[test] fn sap_and_jitter_roundtrip() { let original = Catalog { - version: 1, tracks: vec![track_with_sap_and_jitter()], }; @@ -503,6 +686,220 @@ mod test { assert_eq!(original, parsed); assert_eq!(parsed.tracks[0].max_grp_sap_starting_type, Some(1)); assert_eq!(parsed.tracks[0].max_obj_sap_starting_type, Some(2)); - assert_eq!(parsed.tracks[0].jitter, Some(15.5)); + assert_eq!(parsed.tracks[0].jitter, Some(Duration::from_millis(15))); + } + + #[test] + fn serialize_emits_draft01_version() { + // Callers never set a version; we always emit the newest draft string. + let json = Catalog::default().to_string().unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["version"], serde_json::json!("draft-01")); + } + + #[test] + fn draft00_numeric_version_decodes_and_normalizes() { + // draft-00 put the JSON number 1 in `version`. It must decode, and on + // re-serialize we normalize to the current draft string. + let catalog = Catalog::from_str(r#"{"version":1,"tracks":[]}"#).unwrap(); + assert!(catalog.tracks.is_empty()); + + let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap(); + assert_eq!(value["version"], serde_json::json!("draft-01")); + } + + #[test] + fn draft01_string_version_decodes() { + let catalog = Catalog::from_str(r#"{"version":"draft-01","tracks":[]}"#).unwrap(); + assert!(catalog.tracks.is_empty()); + } + + #[test] + fn unknown_version_string_is_accepted() { + // A future draft we don't specifically recognize still decodes; we don't + // expose the version, so callers are unaffected. + assert!(Catalog::from_str(r#"{"version":"draft-99","tracks":[]}"#).is_ok()); + } + + #[test] + fn unsupported_numeric_version_errors() { + // Numbers other than 1 never had a defined meaning, so reject them. + assert!(Catalog::from_str(r#"{"version":2,"tracks":[]}"#).is_err()); + } + + #[test] + fn float_numeric_version_is_accepted() { + // `1.0` is a valid JSON spelling of the draft-00 version; accept it so we + // don't reject a catalog the JS decoder would happily parse. + assert!(Catalog::from_str(r#"{"version":1.0,"tracks":[]}"#).is_ok()); + assert!(Catalog::from_str(r#"{"version":2.0,"tracks":[]}"#).is_err()); + } + + #[test] + fn unresolved_init_ref_leaves_init_data_none() { + // A dangling initRef (no matching entry, or a non-inline type) resolves to + // no init data rather than failing the whole catalog. Downstream decides + // whether a track without init data is usable. + let json = r#"{ + "version": "draft-01", + "initDataList": [ + { "id": "v0", "type": "url", "data": "https://example.com/init" } + ], + "tracks": [ + { "name": "a", "packaging": "cmaf", "isLive": true, "role": "video", + "codec": "avc1.640028", "initRef": "missing" }, + { "name": "b", "packaging": "cmaf", "isLive": true, "role": "video", + "codec": "avc1.640028", "initRef": "v0" } + ] + }"#; + + let catalog = Catalog::from_str(json).unwrap(); + assert_eq!(catalog.tracks[0].init_data, None); + assert_eq!(catalog.tracks[1].init_data, None); + } + + #[test] + fn draft01_init_ref_resolves_to_inline() { + // draft-01 carries init data in a root initDataList; tracks reference it by + // id via initRef. Parsing must resolve that into inline init_data. + let json = r#"{ + "version": "draft-01", + "initDataList": [ + { "id": "v0", "type": "inline", "data": "AQID" } + ], + "tracks": [ + { "name": "video0", "packaging": "cmaf", "isLive": true, "role": "video", + "codec": "avc1.640028", "initRef": "v0" } + ] + }"#; + + let catalog = Catalog::from_str(json).unwrap(); + assert_eq!(catalog.tracks[0].init_data.as_deref(), Some("AQID")); + } + + #[test] + fn serialize_hoists_and_dedups_init_data() { + // Two tracks sharing the same init payload must collapse to a single + // initDataList entry, with both tracks referencing it via initRef and no + // inline initData left on the tracks. + let mut a = video_track(); + a.name = "a".to_string(); + a.init_data = Some("AQID".to_string()); + let mut b = video_track(); + b.name = "b".to_string(); + b.init_data = Some("AQID".to_string()); + + let catalog = Catalog { tracks: vec![a, b] }; + let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap(); + + let list = value["initDataList"].as_array().expect("initDataList present"); + assert_eq!(list.len(), 1, "identical payloads should dedup to one entry"); + assert_eq!(list[0]["data"], serde_json::json!("AQID")); + assert_eq!(list[0]["type"], serde_json::json!("inline")); + + let id = list[0]["id"].as_str().unwrap(); + for t in value["tracks"].as_array().unwrap() { + assert_eq!(t["initRef"], serde_json::json!(id)); + assert!(t.get("initData").is_none(), "no inline initData on the wire"); + } + + // And it round-trips back to inline init_data for both tracks. + let parsed = Catalog::from_str(&catalog.to_string().unwrap()).unwrap(); + assert_eq!(parsed.tracks[0].init_data.as_deref(), Some("AQID")); + assert_eq!(parsed.tracks[1].init_data.as_deref(), Some("AQID")); + } + + #[test] + fn draft00_example_av_decodes() { + // Example 1 from draft-ietf-moq-msf-00: time-aligned audio/video. Exercises the + // numeric version, integer framerate into an f64 field, and unmodeled fields + // (namespace, targetLatency, generatedAt) which must be ignored, not rejected. + let json = r#"{ + "version": 1, + "generatedAt": 1746104606044, + "tracks": [ + { + "name": "1080p-video", + "namespace": "conference.example.com/conference123/alice", + "packaging": "loc", + "isLive": true, + "targetLatency": 2000, + "role": "video", + "renderGroup": 1, + "codec": "av01.0.08M.10.0.110.09", + "width": 1920, + "height": 1080, + "framerate": 30, + "bitrate": 1500000 + }, + { + "name": "audio", + "namespace": "conference.example.com/conference123/alice", + "packaging": "loc", + "isLive": true, + "targetLatency": 2000, + "role": "audio", + "codec": "opus", + "samplerate": 48000, + "channelConfig": "2", + "bitrate": 32000 + } + ] + }"#; + + let catalog = Catalog::from_str(json).expect("draft-00 AV catalog must decode"); + assert_eq!(catalog.tracks.len(), 2); + assert_eq!(catalog.tracks[0].framerate, Some(30.0)); + assert_eq!(catalog.tracks[1].channel_config.as_deref(), Some("2")); + } + + #[test] + fn draft00_example_timeline_tracks_decode() { + // Example 8 from draft-ietf-moq-msf-00: mediatimeline/eventtimeline tracks omit + // isLive/role/codec entirely. The whole catalog must still decode. + let json = r#"{ + "version": 1, + "generatedAt": 1746104606044, + "tracks": [ + { + "name": "history", + "namespace": "conference.example.com/conference123/alice", + "packaging": "mediatimeline", + "mimetype": "application/json", + "depends": ["1080p-video", "audio"] + }, + { + "name": "1080p-video", + "namespace": "conference.example.com/conference123/alice", + "packaging": "loc", + "isLive": true, + "role": "video", + "codec": "av01.0.08M.10.0.110.09", + "width": 1920, + "height": 1080, + "framerate": 30, + "bitrate": 1500000 + } + ] + }"#; + + let catalog = Catalog::from_str(json).expect("draft-00 timeline catalog must decode"); + assert_eq!(catalog.tracks.len(), 2); + // The timeline track had no isLive; it must default rather than fail the parse. + assert!(!catalog.tracks[0].is_live); + assert_eq!(catalog.tracks[0].packaging, Packaging::MediaTimeline); + } + + #[test] + fn draft00_example_complete_decodes() { + // Example 9: terminating a live broadcast (isComplete, empty tracks). + let json = r#"{ + "version": 1, + "generatedAt": 1746104606044, + "isComplete": true, + "tracks": [] + }"#; + let catalog = Catalog::from_str(json).expect("draft-00 completion catalog must decode"); + assert!(catalog.tracks.is_empty()); } } diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index b54da1dc5..bcd921b29 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Emit MSF catalogs at draft-ietf-moq-msf-01: `version` is the string `"draft-01"` and init data is carried via the root `initDataList` + per-track `initRef`. The MSF consumer still accepts draft-00 (numeric `version`, inline `initData`). + ## [0.6.0](https://github.com/moq-dev/moq/compare/moq-mux-v0.5.6...moq-mux-v0.6.0) - 2026-06-23 ### Added diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 731e09a21..506db76f3 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -23,7 +23,6 @@ bytes = "1" h264-parser = { version = "0.4.0" } hang = { workspace = true } kio = { workspace = true } -m3u8-rs = { version = "6" } memchr = "2" moq-json = { workspace = true } moq-loc = { workspace = true } @@ -32,14 +31,13 @@ moq-net = { workspace = true, features = ["serde"] } mp4-atom = { version = "0.11", features = ["tokio", "bytes", "serde"] } mpeg2ts = "0.6.0" num_enum = "0.7" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] } scuffle-av1 = { version = "0.1.4" } scuffle-h265 = { version = "0.2.2" } serde = { workspace = true } serde_json = "1" serde_with = { version = "3", features = ["base64"] } thiserror = "2" -tokio = { workspace = true, features = ["macros", "fs"] } +tokio = { workspace = true, features = ["macros"] } tracing = "0.1" url = "2" webm-iterable = "0.6" diff --git a/rs/moq-mux/src/catalog/consumer.rs b/rs/moq-mux/src/catalog/consumer.rs new file mode 100644 index 000000000..54932c8de --- /dev/null +++ b/rs/moq-mux/src/catalog/consumer.rs @@ -0,0 +1,63 @@ +//! Unified catalog consumer. +//! +//! Subscribes to whichever catalog track ([`hang`] or [`msf`]) the broadcast +//! advertises and yields [`Catalog`](super::hang::Catalog) snapshots so callers +//! and exporters only deal with one shape. + +use std::task::{Poll, ready}; + +use super::hang::{Catalog, CatalogExt}; +use super::{CatalogFormat, Stream}; + +/// A catalog stream sourced from a [`moq_net::BroadcastConsumer`]. +/// +/// Both variants emit [`Catalog`](super::hang::Catalog); the MSF variant is +/// media-only, so its extension is always the default. Wrap with +/// [`Filter`](super::Filter) / [`Target`](super::Target) to narrow the +/// rendition set before handing the stream to an exporter. +pub enum Consumer { + Hang(super::hang::Consumer), + Msf(super::msf::Consumer), +} + +impl Consumer { + /// Subscribe to the catalog track advertised by `format`. + pub fn new(broadcast: &moq_net::BroadcastConsumer, format: CatalogFormat) -> Result { + Ok(match format { + CatalogFormat::Hang => { + let track = broadcast.subscribe_track(&moq_net::Track::new(hang::Catalog::DEFAULT_NAME))?; + Self::Hang(super::hang::Consumer::new(track)) + } + CatalogFormat::HangZ => { + let track = broadcast.subscribe_track(&hang::Catalog::compressed_track())?; + Self::Hang(super::hang::Consumer::compressed(track)) + } + CatalogFormat::Msf => { + let track = broadcast.subscribe_track(&moq_net::Track::new(moq_msf::DEFAULT_NAME))?; + Self::Msf(super::msf::Consumer::new(track)) + } + }) + } +} + +impl Stream for Consumer { + type Ext = E; + + fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { + match self { + Self::Hang(c) => c.poll_next(waiter), + Self::Msf(c) => { + // MSF carries only the media sections, so the extension defaults. + let media = match ready!(c.poll_next(waiter)) { + Ok(media) => media, + Err(err) => return Poll::Ready(Err(err)), + }; + Poll::Ready(Ok(media.map(|m| Catalog:: { + video: m.video, + audio: m.audio, + ext: E::default(), + }))) + } + } + } +} diff --git a/rs/moq-mux/src/catalog/filter.rs b/rs/moq-mux/src/catalog/filter.rs new file mode 100644 index 000000000..d032397ed --- /dev/null +++ b/rs/moq-mux/src/catalog/filter.rs @@ -0,0 +1,317 @@ +//! Hard-match rendition filter. +//! +//! [`Filter`] wraps any [`Stream`] and drops renditions that don't satisfy a +//! [`FilterVideo`] / [`FilterAudio`]. Matching is exact: a `name` constraint +//! keeps only the rendition with that key, a `codec` constraint keeps only +//! renditions whose codec family matches. Multiple constraints intersect. + +use std::task::Poll; + +use hang::catalog::{AudioCodecKind, VideoCodecKind}; + +use super::Stream; +use super::hang::{Catalog, CatalogExt}; + +/// Hard-match criteria for video renditions. +#[derive(Debug, Default, Clone)] +pub struct FilterVideo { + /// Keep only the rendition with this exact name. + pub name: Option, + /// Keep only renditions whose codec family matches. + pub codec: Option, +} + +/// Hard-match criteria for audio renditions. +#[derive(Debug, Default, Clone)] +pub struct FilterAudio { + /// Keep only the rendition with this exact name. + pub name: Option, + /// Keep only renditions whose codec family matches. + pub codec: Option, +} + +/// Shared state behind a [`Filter`]. +/// +/// `epoch` advances on every setter so [`Filter::poll_next`] can tell whether +/// the criteria changed since the last emit. +#[derive(Debug, Default, Clone)] +struct FilterState { + video: Option, + audio: Option, + epoch: u64, +} + +/// A [`Stream`] that drops renditions failing a [`FilterVideo`] / [`FilterAudio`]. +/// +/// Selection criteria live behind a [`kio::Producer`], so calls to +/// [`set_video`](Self::set_video) / [`set_audio`](Self::set_audio) wake any +/// pending `poll_next` instead of silently waiting for the next upstream +/// snapshot. +pub struct Filter { + inner: S, + state: kio::Producer, + state_consumer: kio::Consumer, + /// Last raw snapshot from `inner`, retained so a setter between snapshots + /// can re-apply without polling upstream. + last_input: Option>, + /// Epoch we already emitted against. + last_epoch: u64, + /// True once `inner` has handed us a snapshot we haven't emitted yet. + fresh_input: bool, +} + +impl Filter { + pub fn new(inner: S) -> Self { + let state = kio::Producer::new(FilterState::default()); + let state_consumer = state.consume(); + Self { + inner, + state, + state_consumer, + last_input: None, + last_epoch: 0, + fresh_input: false, + } + } + + /// Set or clear the video filter. Pass `None` to clear. + pub fn set_video(&mut self, filter: impl Into>) { + self.update(|s| s.video = filter.into()); + } + + /// Set or clear the audio filter. Pass `None` to clear. + pub fn set_audio(&mut self, filter: impl Into>) { + self.update(|s| s.audio = filter.into()); + } + + fn update(&self, f: impl FnOnce(&mut FilterState)) { + // `write()` only errors when the producer is closed, which can't happen + // while `self` holds the only producer handle. + let Ok(mut state) = self.state.write() else { + return; + }; + f(&mut state); + state.epoch = state.epoch.wrapping_add(1); + // Mut::drop wakes the paired consumer waiters here. + } +} + +impl Stream for Filter { + type Ext = S::Ext; + + fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { + let inner_eof = loop { + match self.inner.poll_next(waiter)? { + Poll::Ready(Some(snapshot)) => { + self.last_input = Some(snapshot); + self.fresh_input = true; + } + Poll::Ready(None) => break true, + Poll::Pending => break false, + } + }; + + let last_epoch = self.last_epoch; + let fresh_input = self.fresh_input; + let last_input = self.last_input.clone(); + + let polled = self.state_consumer.poll(waiter, |state| { + let filter_changed = state.epoch != last_epoch; + if !fresh_input && !filter_changed { + return Poll::Pending; + } + let Some(input) = last_input.clone() else { + return Poll::Pending; + }; + let emit = apply(input, state.video.as_ref(), state.audio.as_ref()); + Poll::Ready((emit, state.epoch)) + }); + + match polled { + Poll::Ready(Ok((emit, epoch))) => { + self.last_epoch = epoch; + self.fresh_input = false; + Poll::Ready(Ok(Some(emit))) + } + Poll::Ready(Err(_)) => Poll::Ready(Ok(None)), + Poll::Pending => { + if inner_eof && self.last_input.is_none() { + Poll::Ready(Ok(None)) + } else { + Poll::Pending + } + } + } + } +} + +/// Apply the active video / audio filters to a raw snapshot, dropping +/// renditions that don't match. Axes with no filter pass through unchanged. +fn apply( + mut catalog: Catalog, + video: Option<&FilterVideo>, + audio: Option<&FilterAudio>, +) -> Catalog { + if let Some(filter) = video { + catalog.video.renditions.retain(|name, config| { + if let Some(want) = &filter.name + && want != name + { + return false; + } + if let Some(want) = filter.codec + && config.codec.kind() != want + { + return false; + } + true + }); + } + if let Some(filter) = audio { + catalog.audio.renditions.retain(|name, config| { + if let Some(want) = &filter.name + && want != name + { + return false; + } + if let Some(want) = filter.codec + && config.codec.kind() != want + { + return false; + } + true + }); + } + catalog +} + +#[cfg(test)] +mod test { + use std::collections::BTreeMap; + + use hang::catalog::{AudioCodec, AudioConfig, Container, H264, VideoConfig}; + + use super::*; + + struct Once(Option); + + impl Stream for Once { + type Ext = (); + + fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { + Poll::Ready(Ok(self.0.take())) + } + } + + fn h264(name: &str) -> (String, VideoConfig) { + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + config.coded_width = Some(640); + config.coded_height = Some(360); + config.bitrate = Some(500_000); + config.framerate = Some(30.0); + config.container = Container::Legacy; + (name.to_string(), config) + } + + fn opus(name: &str) -> (String, AudioConfig) { + let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, 2); + config.bitrate = Some(128_000); + config.container = Container::Legacy; + (name.to_string(), config) + } + + fn catalog_with(video: Vec<(String, VideoConfig)>, audio: Vec<(String, AudioConfig)>) -> Catalog { + let mut c = Catalog::default(); + c.video.renditions = BTreeMap::from_iter(video); + c.audio.renditions = BTreeMap::from_iter(audio); + c + } + + #[test] + fn codec_filter_keeps_matching() { + let mut hd = h264("hd"); + hd.1.codec = hang::catalog::VP9 { + profile: 0, + level: 10, + bit_depth: 8, + chroma_subsampling: 1, + color_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + full_range: false, + } + .into(); + let snapshot = catalog_with(vec![h264("lo"), hd], vec![]); + + let mut f = Filter::new(Once(Some(snapshot))); + f.set_video(FilterVideo { + codec: Some(VideoCodecKind::H264), + ..Default::default() + }); + + let out = match f.poll_next(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("expected snapshot, got {other:?}"), + }; + assert_eq!(out.video.renditions.keys().collect::>(), vec!["lo"]); + } + + #[test] + fn name_filter_exact() { + let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); + let mut f = Filter::new(Once(Some(snapshot))); + f.set_video(FilterVideo { + name: Some("hi".into()), + ..Default::default() + }); + let out = match f.poll_next(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("got {other:?}"), + }; + assert_eq!(out.video.renditions.keys().collect::>(), vec!["hi"]); + } + + #[test] + fn audio_filter_independent_of_video() { + let snapshot = catalog_with(vec![h264("hi")], vec![opus("en"), opus("es")]); + let mut f = Filter::new(Once(Some(snapshot))); + f.set_audio(FilterAudio { + name: Some("es".into()), + ..Default::default() + }); + let out = match f.poll_next(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("got {other:?}"), + }; + assert_eq!(out.video.renditions.keys().collect::>(), vec!["hi"]); + assert_eq!(out.audio.renditions.keys().collect::>(), vec!["es"]); + } + + #[test] + fn set_video_after_snapshot_reemits() { + let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); + let mut f = Filter::new(Once(Some(snapshot))); + + let first = match f.poll_next(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("got {other:?}"), + }; + assert_eq!(first.video.renditions.len(), 2); + + f.set_video(FilterVideo { + name: Some("hi".into()), + ..Default::default() + }); + + let again = match f.poll_next(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("expected re-emit, got {other:?}"), + }; + assert_eq!(again.video.renditions.keys().collect::>(), vec!["hi"]); + } +} diff --git a/rs/moq-mux/src/catalog/format.rs b/rs/moq-mux/src/catalog/format.rs index 8cf904e44..357d76082 100644 --- a/rs/moq-mux/src/catalog/format.rs +++ b/rs/moq-mux/src/catalog/format.rs @@ -29,9 +29,6 @@ impl CatalogFormat { pub const DEFAULT: Self = Self::Hang; /// The filename-style suffix (including leading dot) for this format. - /// - /// [`Hang`](Self::Hang) and [`HangZ`](Self::HangZ) share `.hang`: the compressed track is an - /// extra track on the same broadcast, not a different broadcast name. pub fn extension(self) -> &'static str { match self { Self::Hang | Self::HangZ => ".hang", @@ -74,12 +71,4 @@ mod test { assert_eq!(CatalogFormat::detect(""), None); assert_eq!(CatalogFormat::detect("demo/foo.v2"), None); } - - #[test] - fn hangz_shares_hang_suffix_and_is_never_detected() { - // HangZ is an explicit opt-in: it reuses the `.hang` broadcast suffix and detection always - // resolves `.hang` to the uncompressed track. - assert_eq!(CatalogFormat::HangZ.extension(), ".hang"); - assert_eq!(CatalogFormat::detect("demo/bbb.hang"), Some(CatalogFormat::Hang)); - } } diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index 8b9b58928..fcc1c5360 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -1,6 +1,6 @@ use std::task::{Poll, ready}; -use super::{Catalog, CatalogExt, Extra}; +use super::{Catalog, CatalogExt}; use crate::Result; /// A catalog consumer, used to receive catalog updates and discover tracks. @@ -8,23 +8,14 @@ use crate::Result; /// This wraps a [`moq_json::Consumer`], reconstructing the JSON catalog from the latest /// group's snapshot (plus any future deltas) to discover available audio and video tracks. /// -/// Generic over the application extension `E` (defaulting to [`Extra`](super::Extra), the -/// untyped JSON passthrough); yields a [`Catalog`](super::Catalog). -pub struct Consumer { +/// Generic over the application extension `E` (defaulting to `()`); yields a +/// [`Catalog`](super::Catalog). +pub struct Consumer { inner: moq_json::Consumer>, } -// Manual Clone so a consumer is cheaply clonable regardless of whether `E` is. -impl Clone for Consumer { - fn clone(&self) -> Self { - Self { - inner: self.inner.clone(), - } - } -} - impl Consumer { - /// Create a new catalog consumer from a MoQ track consumer (uncompressed `catalog.json`). + /// Create a new catalog consumer from a MoQ track subscriber (uncompressed `catalog.json`). pub fn new(track: moq_net::TrackConsumer) -> Self { Self { inner: moq_json::Consumer::new(track, moq_json::ConsumerConfig::default()), @@ -72,6 +63,15 @@ mod test { use super::*; + /// Mint a standalone track for tests via a throwaway broadcast, since tracks are + /// born from their broadcast (no public `TrackProducer::new`). + fn track_producer(name: impl Into) -> moq_net::TrackProducer { + moq_net::Broadcast::new() + .produce() + .create_track(moq_net::Track::new(name)) + .unwrap() + } + // Build a base catalog distinguished by an audio rendition named `name`, plus its JSON payload. fn catalog_payload(name: &str) -> (Catalog, String) { let mut catalog = Catalog::default(); @@ -92,7 +92,7 @@ mod test { #[test] fn waits_for_pending_catalog_group_payload() { - let mut track = hang::Catalog::default_track().produce(); + let mut track = track_producer(hang::Catalog::DEFAULT_NAME); let mut consumer = Consumer::new(track.consume()); let mut group = track.append_group().expect("catalog group should append"); @@ -108,7 +108,7 @@ mod test { #[test] fn waits_for_pending_catalog_group_payload_after_track_finish() { - let mut track = hang::Catalog::default_track().produce(); + let mut track = track_producer(hang::Catalog::DEFAULT_NAME); let mut consumer = Consumer::new(track.consume()); let mut group = track.append_group().expect("catalog group should append"); @@ -126,7 +126,7 @@ mod test { #[test] fn returns_latest_complete_catalog_group() { - let mut track = hang::Catalog::default_track().produce(); + let mut track = track_producer(hang::Catalog::DEFAULT_NAME); let mut consumer = Consumer::new(track.consume()); let waiter = kio::Waiter::noop(); @@ -152,7 +152,7 @@ mod test { #[test] fn waits_for_newer_pending_group_instead_of_returning_older_ready_group() { - let mut track = hang::Catalog::default_track().produce(); + let mut track = track_producer(hang::Catalog::DEFAULT_NAME); let mut consumer = Consumer::new(track.consume()); let waiter = kio::Waiter::noop(); @@ -179,7 +179,7 @@ mod test { #[test] fn retained_pending_group_is_superseded_by_newer_group() { - let mut track = hang::Catalog::default_track().produce(); + let mut track = track_producer(hang::Catalog::DEFAULT_NAME); let mut consumer = Consumer::new(track.consume()); let waiter = kio::Waiter::noop(); @@ -209,7 +209,7 @@ mod test { #[test] fn returns_none_when_empty_track_finishes() { - let mut track = hang::Catalog::default_track().produce(); + let mut track = track_producer(hang::Catalog::DEFAULT_NAME); let mut consumer: Consumer = Consumer::new(track.consume()); let waiter = kio::Waiter::noop(); diff --git a/rs/moq-mux/src/catalog/hang/ext.rs b/rs/moq-mux/src/catalog/hang/ext.rs index 4d6fdbe23..d0842b2ae 100644 --- a/rs/moq-mux/src/catalog/hang/ext.rs +++ b/rs/moq-mux/src/catalog/hang/ext.rs @@ -27,17 +27,17 @@ use serde::{Deserialize, Serialize}; /// ``` /// /// The unit type `()` is the no-extension case, so [`Catalog<()>`] is just the base media catalog. -pub trait CatalogExt: Serialize + DeserializeOwned + Default + Clone + Send + 'static {} +pub trait CatalogExt: Serialize + DeserializeOwned + Default + Clone + Send + Unpin + 'static {} impl CatalogExt for () {} /// The untyped catalog extension: arbitrary top-level JSON sections beyond the base /// `video`/`audio` media sections, captured and republished verbatim. /// -/// This is the default extension (so a plain [`Catalog`] preserves sections it doesn't -/// recognize instead of dropping them, matching the permissive JS catalog schema). Reach -/// for a typed [`CatalogExt`] struct instead when you want compile-time fields; reach for -/// `()` when you explicitly want unknown sections dropped. +/// This is the extension a caller reaches for when the section names aren't known at +/// compile time, e.g. across the FFI/C boundary where a typed [`CatalogExt`] struct can't +/// cross. Publish/consume a [`Catalog`] and use [`set`](Self::set)/[`get`](Self::get). +/// The default extension stays `()` (unknown sections dropped); opt into `Extra` explicitly. /// /// `video` and `audio` are reserved for the base media sections, so [`set`](Self::set) /// rejects them to keep the wire JSON free of duplicate keys. @@ -85,16 +85,16 @@ impl Extra { } } -/// The base media sections plus an application extension `E` (defaulting to [`Extra`], the -/// untyped JSON passthrough), serialized as a flat union: the `video`/`audio` sections and the -/// extension's sections share one JSON object on the wire. +/// The base media sections plus an application extension `E` (defaulting to `()` for none), +/// serialized as a flat union: the `video`/`audio` sections and the extension's sections share one +/// JSON object on the wire. /// /// `video` and `audio` are direct fields (`catalog.video`), and the catalog derefs to the extension /// so its sections are reachable directly too (`catalog.scte35`, or `catalog.ext.scte35` /// explicitly). A consumer reading a different extension (or none) ignores sections it doesn't know. #[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)] #[serde(bound(serialize = "E: Serialize", deserialize = "E: DeserializeOwned"))] -pub struct Catalog { +pub struct Catalog { #[serde(default)] pub video: hang::catalog::Video, @@ -193,10 +193,10 @@ mod test { #[test] fn untyped_extra_roundtrip() { let mut broadcast = moq_net::Broadcast::new().produce(); - let mut producer = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut producer = crate::catalog::Producer::new_extra(&mut broadcast).unwrap(); let mut consumer = producer.consume().unwrap(); - // A media section coexists with an arbitrary, untyped application section. + // A media section (flat field) coexists with an arbitrary untyped application section. producer.lock().audio.renditions.insert( "audio0".to_string(), hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2), @@ -225,20 +225,4 @@ mod test { ); assert_eq!(catalog.sections().count(), 1); } - - #[test] - fn untyped_extra_serializes_flat_and_omits_when_empty() { - // An empty extension is byte-identical to the no-extension catalog (wire compatibility). - let empty = Catalog::::default(); - assert_eq!( - serde_json::to_value(&empty).unwrap(), - serde_json::to_value(Catalog::<()>::default()).unwrap() - ); - - // A set section lands as a flat top-level key alongside `video`/`audio`. - let mut catalog = Catalog::::default(); - catalog.ext.set("scte35", serde_json::json!({ "spliceId": 7 })).unwrap(); - let json = serde_json::to_value(&catalog).unwrap(); - assert_eq!(json["scte35"], serde_json::json!({ "spliceId": 7 })); - } } diff --git a/rs/moq-mux/src/catalog/mod.rs b/rs/moq-mux/src/catalog/mod.rs index d58e6404c..e0bb25aef 100644 --- a/rs/moq-mux/src/catalog/mod.rs +++ b/rs/moq-mux/src/catalog/mod.rs @@ -7,16 +7,34 @@ //! - [`hang`] is hang's original shape, served on the `catalog.json` track. //! - [`msf`] is the IETF-proposed alternative, served on the `catalog` track. //! -//! A single [`Producer`] writes both tracks together; subscribers pick one -//! with the format-specific [`hang::Consumer`] or [`msf::Consumer`] based on -//! the broadcast's filename suffix. See [`CatalogFormat`] for the -//! suffix-to-format mapping. +//! Publishing through [`Producer`] writes both tracks together; +//! subscribers pick one based on the broadcast's filename suffix. See +//! [`CatalogFormat`] for the suffix-to-format mapping. The producer is +//! generic over an application extension `E` (see [`hang::CatalogExt`]), +//! defaulting to `()` for media-only catalogs. +//! +//! On the consume side, [`Consumer`] is the unified entry point: it +//! subscribes to whichever catalog track `format` advertises and yields +//! [`Catalog`](hang::Catalog) snapshots. Wrap it with [`Filter`] (hard +//! match on name / codec family) or [`Target`] (soft match picking one +//! rendition per axis) to narrow the set before handing it to an exporter; +//! both also implement [`Stream`] so they compose either direction. pub mod hang; pub mod msf; +mod consumer; +mod filter; mod format; mod producer; +mod stream; +mod target; +mod tracks; +pub use consumer::Consumer; +pub use filter::{Filter, FilterAudio, FilterVideo}; pub use format::*; pub use producer::{Guard, Producer}; +pub use stream::Stream; +pub use target::{Target, TargetAudio, TargetVideo}; +pub use tracks::{AudioTrack, VideoTrack}; diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index fcccaa976..d0d3e6ca6 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -1,10 +1,12 @@ use std::str::FromStr; use std::task::Poll; -use anyhow::Context; use base64::Engine; use hang::catalog::{AudioCodec, AudioConfig, Container, VideoCodec, VideoConfig}; +use crate::Result; +use crate::catalog::msf::Error; + /// A consumer for the MSF catalog track. /// /// Mirrors [`crate::catalog::hang::Consumer`] but for the MSF (MOQT Streaming Format) catalog @@ -25,7 +27,7 @@ impl Consumer { } /// Poll for the next catalog update, returned as a [`hang::Catalog`]. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { // Drain pending groups, keeping only the newest. Remember whether the track is done // so we can distinguish "more groups may arrive" from "no more groups, ever". let track_finished = loop { @@ -40,8 +42,8 @@ impl Consumer { match group.poll_read_frame(waiter)? { Poll::Ready(Some(frame)) => { self.group = None; - let json = std::str::from_utf8(&frame).context("MSF catalog frame is not valid UTF-8")?; - let msf = moq_msf::Catalog::from_str(json).context("failed to parse MSF catalog frame")?; + let json = std::str::from_utf8(&frame).map_err(|_| Error::InvalidUtf8)?; + let msf = moq_msf::Catalog::from_str(json).map_err(|_| Error::ParseFrame)?; let catalog = from_msf(&msf)?; return Poll::Ready(Ok(Some(catalog))); } @@ -61,7 +63,7 @@ impl Consumer { /// /// Waits for the next MSF catalog publication and returns it converted to a /// [`hang::Catalog`]. Returns `None` when the track has ended with no further updates. - pub async fn next(&mut self) -> anyhow::Result> { + pub async fn next(&mut self) -> Result> { kio::wait(|waiter| self.poll_next(waiter)).await } } @@ -86,7 +88,7 @@ impl From for Consumer { /// /// Fields with no representation in `hang::Catalog` (`is_live`, `render_group`, `alt_group`, /// `max_grp_sap_starting_type`, `max_obj_sap_starting_type`) are dropped. -pub(crate) fn from_msf(msf: &moq_msf::Catalog) -> anyhow::Result { +pub(crate) fn from_msf(msf: &moq_msf::Catalog) -> Result { let mut catalog = hang::Catalog::default(); for track in &msf.tracks { @@ -139,14 +141,12 @@ pub(crate) fn from_msf(msf: &moq_msf::Catalog) -> anyhow::Result /// Returns `Err` when a CMAF track is missing or has malformed `init_data`. This is an /// intentional hard error: a CMAF rendition is unusable without its `ftyp+moov` init /// segment, and silently skipping it would mask a publisher bug. -fn container_from_msf(track: &moq_msf::Track) -> anyhow::Result> { +fn container_from_msf(track: &moq_msf::Track) -> Result> { match &track.packaging { // Both LOC and Legacy represent raw payloads without ISO-BMFF boxing. moq_msf::Packaging::Loc | moq_msf::Packaging::Legacy => Ok(Some(Container::Legacy)), moq_msf::Packaging::Cmaf => { - let init = decode_init_data(track)? - .with_context(|| format!("MSF CMAF track {:?} missing init_data", track.name))?; - #[allow(deprecated)] + let init = decode_init_data(track)?.ok_or_else(|| Error::MissingCmafInit(track.name.clone()))?; Ok(Some(Container::Cmaf { init, timescale: None, @@ -165,7 +165,7 @@ fn container_from_msf(track: &moq_msf::Track) -> anyhow::Result anyhow::Result> { +fn decode_init_data(track: &moq_msf::Track) -> Result> { track .init_data .as_ref() @@ -173,7 +173,7 @@ fn decode_init_data(track: &moq_msf::Track) -> anyhow::Result anyhow::Result anyhow::Result> { +fn legacy_description(track: &moq_msf::Track) -> Result> { match track.packaging { moq_msf::Packaging::Loc | moq_msf::Packaging::Legacy => decode_init_data(track), _ => Ok(None), } } -fn video_config_from_msf(track: &moq_msf::Track) -> anyhow::Result> { +fn video_config_from_msf(track: &moq_msf::Track) -> Result> { // Unsupported packaging (e.g. MediaTimeline) bubbles up as Ok(None) so the caller can // skip the track with a warning rather than fail the whole catalog. let Some(container) = container_from_msf(track)? else { @@ -199,11 +199,13 @@ fn video_config_from_msf(track: &moq_msf::Track) -> anyhow::Result anyhow::Result= 0.0) - .and_then(|v| moq_net::Time::from_millis(v as u64).ok()); + config.jitter = track.jitter.and_then(|j| moq_net::Time::try_from(j).ok()); Ok(Some(config)) } -fn audio_config_from_msf(track: &moq_msf::Track) -> anyhow::Result> { +fn audio_config_from_msf(track: &moq_msf::Track) -> Result> { let Some(container) = container_from_msf(track)? else { return Ok(None); }; @@ -231,9 +226,11 @@ fn audio_config_from_msf(track: &moq_msf::Track) -> anyhow::Result anyhow::Result= 0.0) - .and_then(|v| moq_net::Time::from_millis(v as u64).ok()); + config.jitter = track.jitter.and_then(|j| moq_net::Time::try_from(j).ok()); Ok(Some(config)) } @@ -283,81 +273,64 @@ struct DerivedAudio { /// Returns an error if `init_data` is absent, malformed, or doesn't carry usable audio /// parameters. The caller is expected to surface this as a hard failure rather than /// substitute defaults: a wrong sample rate produces silent or distorted playback. -fn derive_audio_params(track: &moq_msf::Track, codec: &AudioCodec) -> anyhow::Result { - let init = decode_init_data(track)?.with_context(|| { - format!( - "MSF audio track {:?} omits samplerate/channelConfig and has no init_data to derive from", - track.name - ) - })?; +fn derive_audio_params(track: &moq_msf::Track, codec: &AudioCodec) -> Result { + let init = decode_init_data(track)?.ok_or_else(|| Error::MissingAudioParams(track.name.clone()))?; match track.packaging { moq_msf::Packaging::Loc | moq_msf::Packaging::Legacy => derive_from_codec_config(track, codec, init), moq_msf::Packaging::Cmaf => derive_from_cmaf_moov(track, init), - _ => anyhow::bail!( - "MSF audio track {:?} packaging {:?} is unsupported for parameter derivation", - track.name, - track.packaging - ), + _ => Err(Error::UnsupportedDerivationPackaging { + name: track.name.clone(), + packaging: format!("{:?}", track.packaging), + } + .into()), } } -fn derive_from_codec_config( - track: &moq_msf::Track, - codec: &AudioCodec, - init: bytes::Bytes, -) -> anyhow::Result { +fn derive_from_codec_config(track: &moq_msf::Track, codec: &AudioCodec, init: bytes::Bytes) -> Result { use bytes::Buf; let mut buf = init; match codec { AudioCodec::AAC(_) => { - let cfg = crate::codec::aac::Config::parse(&mut buf) - .with_context(|| format!("MSF audio track {:?} has malformed AudioSpecificConfig", track.name))?; - anyhow::ensure!( - !buf.has_remaining(), - "MSF audio track {:?} AudioSpecificConfig has trailing bytes", - track.name, - ); + let cfg = + crate::codec::aac::Config::parse(&mut buf).map_err(|_| Error::MalformedAac(track.name.clone()))?; + if buf.has_remaining() { + return Err(Error::AacTrailingBytes(track.name.clone()).into()); + } Ok(DerivedAudio { sample_rate: cfg.sample_rate, channel_count: cfg.channel_count, }) } AudioCodec::Opus => { - let cfg = crate::codec::opus::Config::parse(&mut buf) - .with_context(|| format!("MSF audio track {:?} has malformed OpusHead", track.name))?; - anyhow::ensure!( - !buf.has_remaining(), - "MSF audio track {:?} OpusHead has trailing bytes", - track.name, - ); + let cfg = + crate::codec::opus::Config::parse(&mut buf).map_err(|_| Error::MalformedOpus(track.name.clone()))?; + if buf.has_remaining() { + return Err(Error::OpusTrailingBytes(track.name.clone()).into()); + } Ok(DerivedAudio { sample_rate: cfg.sample_rate, channel_count: cfg.channel_count, }) } - _ => anyhow::bail!( - "MSF audio track {:?} omits samplerate/channelConfig; codec {:?} has no init_data parser", - track.name, - codec, - ), + _ => Err(Error::UnsupportedDerivationCodec(track.name.clone()).into()), } } -fn derive_from_cmaf_moov(track: &moq_msf::Track, init: bytes::Bytes) -> anyhow::Result { +fn derive_from_cmaf_moov(track: &moq_msf::Track, init: bytes::Bytes) -> Result { use mp4_atom::{Any, DecodeMaybe}; let mut cursor = std::io::Cursor::new(init.as_ref()); let mut moov: Option = None; - while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor) - .with_context(|| format!("MSF audio track {:?} init segment is malformed", track.name))? + while let Some(atom) = + mp4_atom::Any::decode_maybe(&mut cursor).map_err(|_| Error::MalformedInitSegment(track.name.clone()))? { if let Any::Moov(m) = atom { moov = Some(m); break; } } - let moov = moov.with_context(|| format!("MSF audio track {:?} init segment missing moov", track.name))?; + let moov = moov.ok_or_else(|| Error::MissingInitMoov(track.name.clone()))?; // Walk every trak looking for an audio sample entry. A single-track audio init is // the only thing we expect here, but rather than enforce that we just take the first @@ -383,10 +356,7 @@ fn derive_from_cmaf_moov(track: &moq_msf::Track, init: bytes::Bytes) -> anyhow:: } } } - anyhow::bail!( - "MSF audio track {:?} CMAF init has no audio sample entry to derive samplerate/channelConfig from", - track.name, - ) + Err(Error::MissingAudioSampleEntry(track.name.clone()).into()) } #[cfg(test)] @@ -427,7 +397,6 @@ mod test { let expected_init = base64::engine::general_purpose::STANDARD.decode(init_b64).unwrap(); let msf = moq_msf::Catalog { - version: 1, tracks: vec![video_track("video0", moq_msf::Packaging::Cmaf, Some(init_b64))], }; @@ -447,7 +416,6 @@ mod test { #[test] fn loc_audio_yields_legacy_container() { let msf = moq_msf::Catalog { - version: 1, tracks: vec![audio_track("audio0", moq_msf::Packaging::Loc)], }; @@ -476,7 +444,6 @@ mod test { audio.init_data = Some(init_b64); let msf = moq_msf::Catalog { - version: 1, tracks: vec![video, audio], }; @@ -494,7 +461,6 @@ mod test { // must stay None so downstream code reads the bytes from one place only. let init_b64 = "AAAYZ2Z0eXA="; let msf = moq_msf::Catalog { - version: 1, tracks: vec![video_track("video0", moq_msf::Packaging::Cmaf, Some(init_b64))], }; let catalog = from_msf(&msf).unwrap(); @@ -505,10 +471,7 @@ mod test { fn legacy_malformed_init_data_is_error() { let mut track = video_track("video0", moq_msf::Packaging::Legacy, Some("!!!not-base64!!!")); track.codec = Some("avc1.42c01e".to_string()); - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("malformed base64 should error"); assert!( err.to_string().contains("malformed init_data"), @@ -521,10 +484,7 @@ mod test { fn unknown_codec_yields_unknown_variant() { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.codec = Some("weirdcodec".to_string()); - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("unknown codec is not an error"); let video = catalog.video.renditions.get("video0").expect("video0 rendition"); @@ -534,7 +494,6 @@ mod test { #[test] fn cmaf_without_init_data_is_error() { let msf = moq_msf::Catalog { - version: 1, tracks: vec![video_track("video0", moq_msf::Packaging::Cmaf, None)], }; @@ -545,10 +504,7 @@ mod test { #[test] fn empty_catalog_is_empty_hang_catalog() { - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![], - }; + let msf = moq_msf::Catalog { tracks: vec![] }; let catalog = from_msf(&msf).expect("empty catalog should convert"); assert!(catalog.video.renditions.is_empty()); @@ -559,10 +515,7 @@ mod test { fn track_without_role_is_skipped() { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.role = None; - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("no-role track should be skipped, not error"); assert!(catalog.video.renditions.is_empty()); @@ -573,10 +526,7 @@ mod test { fn unsupported_role_is_skipped() { let mut track = audio_track("caption0", moq_msf::Packaging::Legacy); track.role = Some(moq_msf::Role::Caption); - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("unsupported role should be skipped, not error"); assert!(catalog.audio.renditions.is_empty()); @@ -591,10 +541,7 @@ mod test { track.samplerate = None; track.channel_config = None; track.init_data = None; - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("missing fields with no init_data should error"); assert!(err.to_string().contains("no init_data"), "unexpected error: {}", err); @@ -618,10 +565,7 @@ mod test { track.samplerate = None; track.channel_config = None; track.init_data = Some(init_b64); - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("Opus OpusHead should parse"); let audio = catalog.audio.renditions.get("audio0").expect("audio0 rendition"); @@ -645,10 +589,7 @@ mod test { track.samplerate = None; track.channel_config = None; track.init_data = Some(init_b64); - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("AAC AudioSpecificConfig should parse"); let audio = catalog.audio.renditions.get("audio0").expect("audio0 rendition"); @@ -673,10 +614,7 @@ mod test { track.samplerate = Some(24_000); // explicit, must be preserved track.channel_config = None; // missing, derive from init_data track.init_data = Some(init_b64); - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("partial derivation should succeed"); let audio = catalog.audio.renditions.get("audio0").expect("audio0 rendition"); @@ -690,7 +628,6 @@ mod test { let bad = video_track("timeline0", moq_msf::Packaging::MediaTimeline, None); let good = video_track("video0", moq_msf::Packaging::Legacy, None); let msf = moq_msf::Catalog { - version: 1, tracks: vec![bad, good], }; @@ -712,7 +649,6 @@ mod test { bad.codec = None; let good = audio_track("audio0", moq_msf::Packaging::Loc); let msf = moq_msf::Catalog { - version: 1, tracks: vec![bad, good], }; @@ -724,10 +660,7 @@ mod test { #[test] fn unknown_packaging_variant_is_skipped() { let track = video_track("video0", moq_msf::Packaging::Unknown("custom".to_string()), None); - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("unknown packaging should be skipped, not error"); assert!(catalog.video.renditions.is_empty()); @@ -737,10 +670,7 @@ mod test { fn missing_video_codec_is_error() { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.codec = None; - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("missing video codec must error"); let msg = format!("{err:#}"); @@ -754,10 +684,7 @@ mod test { fn missing_audio_codec_is_error() { let mut track = audio_track("audio0", moq_msf::Packaging::Legacy); track.codec = None; - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("missing audio codec must error"); let msg = format!("{err:#}"); @@ -772,10 +699,7 @@ mod test { // avc1 with a too-short profile string is a malformed structured codec. let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.codec = Some("avc1.0".to_string()); - let msf = moq_msf::Catalog { - version: 1, - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("malformed avc1 codec must error"); let msg = format!("{err:#}"); diff --git a/rs/moq-mux/src/catalog/msf/mod.rs b/rs/moq-mux/src/catalog/msf/mod.rs index 595e19cd3..ebe5ce0d2 100644 --- a/rs/moq-mux/src/catalog/msf/mod.rs +++ b/rs/moq-mux/src/catalog/msf/mod.rs @@ -9,3 +9,64 @@ mod consumer; pub use consumer::Consumer; + +/// MSF catalog decoding errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("MSF catalog frame is not valid UTF-8")] + InvalidUtf8, + + #[error("failed to parse MSF catalog frame")] + ParseFrame, + + #[error("MSF CMAF track {0:?} missing init_data")] + MissingCmafInit(String), + + #[error("MSF track {0:?} has malformed init_data")] + MalformedInitData(String), + + #[error("MSF video track {0:?} missing codec")] + MissingVideoCodec(String), + + #[error("MSF audio track {0:?} missing codec")] + MissingAudioCodec(String), + + #[error("MSF video track {name:?} has invalid codec {codec:?}")] + InvalidVideoCodec { name: String, codec: String }, + + #[error("MSF audio track {name:?} has invalid codec {codec:?}")] + InvalidAudioCodec { name: String, codec: String }, + + #[error("MSF audio track {0:?} omits samplerate/channelConfig and has no init_data to derive from")] + MissingAudioParams(String), + + #[error("MSF audio track {name:?} packaging {packaging:?} is unsupported for parameter derivation")] + UnsupportedDerivationPackaging { name: String, packaging: String }, + + #[error("MSF audio track {0:?} has malformed AudioSpecificConfig")] + MalformedAac(String), + + #[error("MSF audio track {0:?} has malformed OpusHead")] + MalformedOpus(String), + + #[error("MSF audio track {0:?} AudioSpecificConfig has trailing bytes")] + AacTrailingBytes(String), + + #[error("MSF audio track {0:?} OpusHead has trailing bytes")] + OpusTrailingBytes(String), + + #[error("MSF audio track {0:?} omits samplerate/channelConfig; codec has no init_data parser")] + UnsupportedDerivationCodec(String), + + #[error("MSF audio track {0:?} init segment is malformed")] + MalformedInitSegment(String), + + #[error("MSF audio track {0:?} init segment missing moov")] + MissingInitMoov(String), + + #[error("MSF audio track {0:?} CMAF init has no audio sample entry to derive samplerate/channelConfig from")] + MissingAudioSampleEntry(String), +} + +pub type Result = std::result::Result; diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 155efd59b..f6b46d803 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -15,20 +15,22 @@ use super::hang::{Catalog, CatalogExt, Consumer, Extra}; /// /// The JSON catalog is updated when tracks are added/removed but is *not* automatically published. /// You'll have to call [`lock`](Self::lock) to update and publish the catalog. -/// Three tracks are published together on drop of the guard: the hang (`catalog.json`), its -/// DEFLATE-compressed `.z` sibling (`catalog.json.z`), and MSF (`catalog`). +/// Both the hang (`catalog.json`) and MSF (`catalog`) tracks are published on drop of the guard. /// -/// The hang tracks are published through [`moq_json`], which currently emits one snapshot per +/// The hang track is published through [`moq_json`], which currently emits one snapshot per /// group (deltas disabled). This routes catalog publishing through the JSON merge-patch helper -/// so deltas can be enabled later without changing the wire format used today. The `.z` track -/// carries the identical catalog, compressed; consumers opt into it via -/// [`CatalogFormat::HangZ`](super::CatalogFormat::HangZ). -pub struct Producer { +/// so deltas can be enabled later without changing the wire format used today. +pub struct Producer { hang: moq_json::Producer>, hangz: moq_json::Producer>, msf_track: moq_net::TrackProducer, current: Arc>>, + + /// Shared wall clock for the broadcast's tracks. Every importer on this catalog + /// gets a clone (a `Copy` of the same epoch), so timestamps they synthesize when + /// a caller has none land on one timeline and audio/video stay in sync. + clock: crate::Clock, } // Manual Clone so a producer is cheaply clonable regardless of whether `E` is. @@ -39,25 +41,35 @@ impl Clone for Producer { hangz: self.hangz.clone(), msf_track: self.msf_track.clone(), current: self.current.clone(), + clock: self.clock, } } } -impl Producer { +impl Producer<()> { /// Create a new catalog producer with the default (empty) catalog. /// - /// The catalog carries the untyped [`Extra`] extension, so application sections can be set - /// later via [`set_section`](Self::set_section). To publish a *typed* extension instead, use - /// [`with_catalog`](Self::with_catalog) with a `Catalog`. + /// To publish an extended catalog, use [`with_catalog`](Self::with_catalog) with a `Catalog`. pub fn new(broadcast: &mut moq_net::BroadcastProducer) -> Result { Self::with_catalog(broadcast, Catalog::default()) } +} + +impl Producer { + /// Create a catalog producer carrying the untyped [`Extra`] extension, so application + /// sections can be set later via [`set_section`](Self::set_section). This is the entry + /// point for callers that work with sections by name (e.g. the FFI boundary); for a typed + /// extension use [`with_catalog`](Self::with_catalog) with a `Catalog`. + pub fn new_extra(broadcast: &mut moq_net::BroadcastProducer) -> Result { + Self::with_catalog(broadcast, Catalog::default()) + } /// Set (or replace) a top-level application catalog section, publishing the updated catalog. /// /// `value` is any JSON document (object, array, string, ...). Errors if `name` collides with a /// reserved media section (`video`/`audio`). This is the untyped counterpart to mutating a - /// typed extension through [`lock`](Self::lock). + /// typed extension through [`lock`](Self::lock), used where section names aren't known at + /// compile time (e.g. across the FFI boundary). pub fn set_section(&mut self, name: impl Into, value: serde_json::Value) -> crate::Result<()> { self.lock().set_section(name, value) } @@ -76,9 +88,15 @@ impl Producer { broadcast: &mut moq_net::BroadcastProducer, catalog: Catalog, ) -> Result { - let hang_track = broadcast.create_track(hang::Catalog::default_track())?; + let hang_track = broadcast.create_track(moq_net::Track { + name: hang::Catalog::DEFAULT_NAME.to_string(), + priority: 0, + })?; let hangz_track = broadcast.create_track(hang::Catalog::compressed_track())?; - let msf_track = broadcast.create_track(moq_net::Track::new(moq_msf::DEFAULT_NAME))?; + let msf_track = broadcast.create_track(moq_net::Track { + name: moq_msf::DEFAULT_NAME.to_string(), + priority: 0, + })?; // Disable deltas for now to stay byte-compatible with consumers that only read snapshots. let mut json_config = moq_json::ProducerConfig::default(); @@ -95,9 +113,22 @@ impl Producer { hangz, msf_track, current: Arc::new(Mutex::new(catalog)), + clock: crate::Clock::new(), }) } + /// Resolve a timestamp, synthesizing one from the broadcast's shared + /// [`Clock`](crate::Clock) when the caller has none. + /// + /// Sharing the clock across the catalog's tracks keeps concurrently-produced + /// audio and video on a single timeline. + pub fn timestamp(&self, hint: Option) -> crate::Result { + match hint { + Some(pts) => Ok(pts), + None => Ok(crate::container::Timestamp::from_micros(self.clock.micros())?), + } + } + /// Get mutable access to the catalog, publishing it after any changes. pub fn lock(&mut self) -> Guard<'_, E> { Guard { @@ -114,6 +145,20 @@ impl Producer { self.current.lock().unwrap().clone() } + /// A handle for one importer to publish a video rendition, retired on drop. + /// + /// See [`VideoTrack`](super::VideoTrack). + pub fn video_track(&self, name: impl Into) -> super::VideoTrack { + super::VideoTrack::new(self.clone(), name) + } + + /// A handle for one importer to publish an audio rendition, retired on drop. + /// + /// See [`AudioTrack`](super::AudioTrack). + pub fn audio_track(&self, name: impl Into) -> super::AudioTrack { + super::AudioTrack::new(self.clone(), name) + } + /// Create a consumer for this catalog, receiving updates as they're published. pub fn consume(&self) -> Result, moq_net::Error> { Ok(Consumer::new(self.hang.consume())) @@ -139,7 +184,7 @@ impl Producer { /// and (through the catalog's own deref) the extension sections are editable directly. /// /// On drop, the hang, compressed-hang, and MSF catalog tracks are updated if the catalog was mutated. -pub struct Guard<'a, E: CatalogExt = Extra> { +pub struct Guard<'a, E: CatalogExt = ()> { catalog: MutexGuard<'a, Catalog>, hang: &'a mut moq_json::Producer>, hangz: &'a mut moq_json::Producer>, @@ -147,6 +192,21 @@ pub struct Guard<'a, E: CatalogExt = Extra> { updated: bool, } +impl Deref for Guard<'_, E> { + type Target = Catalog; + + fn deref(&self) -> &Self::Target { + &self.catalog + } +} + +impl DerefMut for Guard<'_, E> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.updated = true; + &mut self.catalog + } +} + impl Guard<'_, Extra> { /// Set (or replace) a top-level application catalog section, republished on drop. /// @@ -169,21 +229,6 @@ impl Guard<'_, Extra> { } } -impl Deref for Guard<'_, E> { - type Target = Catalog; - - fn deref(&self) -> &Self::Target { - &self.catalog - } -} - -impl DerefMut for Guard<'_, E> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.updated = true; - &mut self.catalog - } -} - impl Drop for Guard<'_, E> { fn drop(&mut self) { if !self.updated { @@ -258,7 +303,7 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { track.alt_group = if has_multiple_video { Some(1) } else { None }; track.max_grp_sap_starting_type = sap_type; track.max_obj_sap_starting_type = sap_type; - track.jitter = config.jitter.map(|t| t.as_millis() as f64); + track.jitter = config.jitter.map(std::time::Duration::from); tracks.push(track); } @@ -289,11 +334,11 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { track.alt_group = if has_multiple_audio { Some(1) } else { None }; track.max_grp_sap_starting_type = Some(1); track.max_obj_sap_starting_type = Some(1); - track.jitter = config.jitter.map(|t| t.as_millis() as f64); + track.jitter = config.jitter.map(std::time::Duration::from); tracks.push(track); } - moq_msf::Catalog { version: 1, tracks } + moq_msf::Catalog { tracks } } #[cfg(test)] @@ -378,7 +423,6 @@ mod test { let msf = to_msf(&catalog); - assert_eq!(msf.version, 1); assert_eq!(msf.tracks.len(), 2); let video = &msf.tracks[0]; @@ -444,7 +488,6 @@ mod test { fn convert_empty() { let catalog = hang::Catalog::default(); let msf = to_msf(&catalog); - assert_eq!(msf.version, 1); assert!(msf.tracks.is_empty()); } @@ -498,14 +541,14 @@ mod test { video_config.coded_height = Some(720); video_config.framerate = Some(30.0); video_config.container = Container::Legacy; - video_config.jitter = Some(moq_net::Time::from_millis_unchecked(100)); + video_config.jitter = Some(moq_net::Time::try_from(std::time::Duration::from_millis(100)).unwrap()); let mut video_renditions = BTreeMap::new(); video_renditions.insert("video0".to_string(), video_config); let mut audio_config = AudioConfig::new(AudioCodec::Opus, 48_000, 2); audio_config.container = Container::Legacy; - audio_config.jitter = Some(moq_net::Time::from_millis_unchecked(40)); + audio_config.jitter = Some(moq_net::Time::try_from(std::time::Duration::from_millis(40)).unwrap()); let mut audio_renditions = BTreeMap::new(); audio_renditions.insert("audio0".to_string(), audio_config); @@ -529,13 +572,13 @@ mod test { // H.264 may carry B-frames, so SAP starting type is 2. assert_eq!(video.max_grp_sap_starting_type, Some(2)); assert_eq!(video.max_obj_sap_starting_type, Some(2)); - assert_eq!(video.jitter, Some(100.0)); + assert_eq!(video.jitter, Some(std::time::Duration::from_millis(100))); let audio = &msf.tracks[1]; assert_eq!(audio.role, Some(moq_msf::Role::Audio)); assert_eq!(audio.max_grp_sap_starting_type, Some(1)); assert_eq!(audio.max_obj_sap_starting_type, Some(1)); - assert_eq!(audio.jitter, Some(40.0)); + assert_eq!(audio.jitter, Some(std::time::Duration::from_millis(40))); } #[test] diff --git a/rs/moq-mux/src/catalog/stream.rs b/rs/moq-mux/src/catalog/stream.rs new file mode 100644 index 000000000..28ac16ec5 --- /dev/null +++ b/rs/moq-mux/src/catalog/stream.rs @@ -0,0 +1,57 @@ +//! Catalog stream trait. +//! +//! [`Stream`] yields a sequence of [`Catalog`](super::hang::Catalog) snapshots. Both the +//! raw [`Consumer`](super::Consumer) and the rendition-selecting +//! [`Filter`](super::Filter) / [`Target`](super::Target) wrappers implement +//! it, so exporters can be written against the trait and the caller picks +//! the selection policy. +//! +//! The yielded catalog carries the application extension `E` (defaulting to +//! `()` for media-only catalogs) via the [`Ext`](Stream::Ext) associated type, +//! so an exporter that only touches `video`/`audio` works for any extension. + +use std::task::Poll; + +use super::hang::{Catalog, CatalogExt}; +use super::{Filter, Target}; + +/// A stream of catalog snapshots. +/// +/// `poll_next` returns the next snapshot (a full catalog, not a delta), or +/// `None` once the underlying track has ended. Late snapshots supersede +/// earlier ones, so an implementation may drop intermediate snapshots. +/// +/// Stream types are required to be `Send + 'static` so they can be moved +/// across threads and held inside exporters without per-call bounds. +pub trait Stream: Send + 'static { + /// The application extension carried by the yielded catalog (`()` for media-only). + type Ext: CatalogExt; + + fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>>; + + /// Wait for the next snapshot. + fn next(&mut self) -> impl std::future::Future>>> + Send + where + Self: Sized, + { + async move { kio::wait(|waiter| self.poll_next(waiter)).await } + } + + /// Wrap this stream in a [`Filter`] that drops renditions which don't + /// match a hard-match criterion (name or codec family). + fn filter(self) -> Filter + where + Self: Sized, + { + Filter::new(self) + } + + /// Wrap this stream in a [`Target`] that reduces each axis to at most + /// one rendition by soft-matching against width / height / bitrate. + fn target(self) -> Target + where + Self: Sized, + { + Target::new(self) + } +} diff --git a/rs/moq-mux/src/catalog/target.rs b/rs/moq-mux/src/catalog/target.rs new file mode 100644 index 000000000..61950e84e --- /dev/null +++ b/rs/moq-mux/src/catalog/target.rs @@ -0,0 +1,475 @@ +//! Soft-match rendition target. +//! +//! [`Target`] wraps any [`Stream`] and reduces each axis (video / audio) to at +//! most one rendition by ranking the input against constraints like maximum +//! width, height, pixels, or bitrate. The ranking algorithm is a Rust port of +//! [js/watch's `#select`](js/watch/src/video/source.ts). + +use std::collections::BTreeMap; +use std::task::Poll; + +use hang::catalog::{AudioConfig, VideoConfig}; + +use super::Stream; +use super::hang::{Catalog, CatalogExt}; + +/// Soft-match constraints for the video rendition. +/// +/// Each `Option` is a *maximum* the selection will try to stay under. When a +/// rendition fits every active maximum, the largest such rendition wins; if +/// nothing fits, the algorithm degrades to the smallest over-budget rendition +/// (per constraint) and intersects across constraints. +#[derive(Debug, Default, Clone)] +pub struct TargetVideo { + pub width: Option, + pub height: Option, + pub pixels: Option, + pub bitrate: Option, +} + +/// Soft-match constraints for the audio rendition. +#[derive(Debug, Default, Clone)] +pub struct TargetAudio { + pub bitrate: Option, +} + +/// Shared state behind a [`Target`]. +/// +/// `epoch` advances on every setter so [`Target::poll_next`] can tell whether +/// the criteria changed since the last emit without diffing the structs. +#[derive(Debug, Default, Clone)] +struct TargetState { + video: Option, + audio: Option, + epoch: u64, +} + +/// A [`Stream`] that picks one rendition per axis from the inner snapshot. +/// +/// Selection criteria live behind a [`kio::Producer`], so calls to +/// [`set_video`](Self::set_video) / [`set_audio`](Self::set_audio) wake any +/// pending `poll_next` instead of silently waiting for the next upstream +/// snapshot. That makes the type usable as the foothold for bandwidth-driven +/// ABR retargeting. +pub struct Target { + inner: S, + state: kio::Producer, + state_consumer: kio::Consumer, + /// Last raw snapshot from `inner`, retained so a target change between + /// snapshots can be re-applied without polling upstream. + last_input: Option>, + /// Epoch we already emitted against. If `state.epoch` advances past this + /// while `last_input` is `Some`, the next poll re-emits. + last_epoch: u64, + /// True once `inner` has handed us a snapshot we haven't emitted yet. + fresh_input: bool, +} + +impl Target { + pub fn new(inner: S) -> Self { + let state = kio::Producer::new(TargetState::default()); + let state_consumer = state.consume(); + Self { + inner, + state, + state_consumer, + last_input: None, + last_epoch: 0, + fresh_input: false, + } + } + + /// Set or clear the video target. Pass `None` to keep every rendition. + pub fn set_video(&mut self, target: impl Into>) { + self.update(|s| s.video = target.into()); + } + + /// Set or clear the audio target. Pass `None` to keep every rendition. + pub fn set_audio(&mut self, target: impl Into>) { + self.update(|s| s.audio = target.into()); + } + + fn update(&self, f: impl FnOnce(&mut TargetState)) { + // `write()` only errors when the producer is closed, which can't happen + // while `self` holds the only producer handle. + let Ok(mut state) = self.state.write() else { + return; + }; + f(&mut state); + state.epoch = state.epoch.wrapping_add(1); + // Mut::drop wakes the paired consumer waiters here. + } +} + +impl Stream for Target { + type Ext = S::Ext; + + fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { + // Drain inner: the latest snapshot wins. `poll_next` registers the + // waiter on its own Pending branch. + let inner_eof = loop { + match self.inner.poll_next(waiter)? { + Poll::Ready(Some(snapshot)) => { + self.last_input = Some(snapshot); + self.fresh_input = true; + } + Poll::Ready(None) => break true, + Poll::Pending => break false, + } + }; + + // Snapshot the fields the inner closure needs so it can borrow them + // without colliding with the `&self.state_consumer` receiver. + let last_epoch = self.last_epoch; + let fresh_input = self.fresh_input; + let last_input = self.last_input.clone(); + + let polled = self.state_consumer.poll(waiter, |state| { + let target_changed = state.epoch != last_epoch; + if !fresh_input && !target_changed { + // Nothing new from inner and nothing new from caller: register + // the waiter on this consumer so the next setter wakes us. + return Poll::Pending; + } + let Some(input) = last_input.clone() else { + // Caller already retargeted, but no upstream snapshot yet to apply. + return Poll::Pending; + }; + let emit = apply(input, state.video.as_ref(), state.audio.as_ref()); + Poll::Ready((emit, state.epoch)) + }); + + match polled { + Poll::Ready(Ok((emit, epoch))) => { + self.last_epoch = epoch; + self.fresh_input = false; + Poll::Ready(Ok(Some(emit))) + } + Poll::Ready(Err(_)) => { + // Producer dropped (impossible while Self holds it); treat as EOF. + Poll::Ready(Ok(None)) + } + Poll::Pending => { + if inner_eof && self.last_input.is_none() { + Poll::Ready(Ok(None)) + } else { + Poll::Pending + } + } + } + } +} + +/// Apply the active video / audio targets to a raw snapshot, narrowing each +/// axis to at most one rendition. Axes with no target pass through unchanged. +fn apply( + mut catalog: Catalog, + video: Option<&TargetVideo>, + audio: Option<&TargetAudio>, +) -> Catalog { + if let Some(target) = video { + if let Some(name) = select_video(&catalog.video.renditions, target) { + let mut kept = BTreeMap::new(); + if let Some(config) = catalog.video.renditions.remove(&name) { + kept.insert(name, config); + } + catalog.video.renditions = kept; + } else { + catalog.video.renditions.clear(); + } + } + + if let Some(target) = audio { + if let Some(name) = select_audio(&catalog.audio.renditions, target) { + let mut kept = BTreeMap::new(); + if let Some(config) = catalog.audio.renditions.remove(&name) { + kept.insert(name, config); + } + catalog.audio.renditions = kept; + } else { + catalog.audio.renditions.clear(); + } + } + + catalog +} + +/// Run all active video rankings and return the highest-ranked rendition +/// present in every ranking, or `None` if the intersection is empty. +fn select_video(renditions: &BTreeMap, target: &TargetVideo) -> Option { + if renditions.is_empty() { + return None; + } + if renditions.len() == 1 { + return renditions.keys().next().cloned(); + } + + let mut rankings: Vec> = Vec::new(); + if let Some(max) = target.pixels { + rankings.push(by_pixels(renditions, max)); + } + if target.width.is_some() || target.height.is_some() { + rankings.push(by_dimensions(renditions, target.width, target.height)); + } + if let Some(max) = target.bitrate { + rankings.push(by_video_bitrate(renditions, max)); + } + + if rankings.is_empty() { + return Some(best_video(renditions)); + } + + intersect_rankings(rankings) +} + +fn select_audio(renditions: &BTreeMap, target: &TargetAudio) -> Option { + if renditions.is_empty() { + return None; + } + if renditions.len() == 1 { + return renditions.keys().next().cloned(); + } + + let mut rankings: Vec> = Vec::new(); + if let Some(max) = target.bitrate { + rankings.push(by_audio_bitrate(renditions, max)); + } + + if rankings.is_empty() { + return Some(best_audio(renditions)); + } + + intersect_rankings(rankings) +} + +/// Pick the first name from `rankings[0]` that appears in every other ranking. +fn intersect_rankings(rankings: Vec>) -> Option { + use std::collections::HashSet; + let sets: Vec> = rankings.iter().map(|r| r.iter().collect()).collect(); + for name in &rankings[0] { + if sets.iter().all(|s| s.contains(name)) { + return Some(name.clone()); + } + } + tracing::warn!("conflicting rendition targets, no rendition satisfies all criteria"); + None +} + +/// Rank by area, largest-first within budget; fall back to single smallest +/// over-budget if nothing fits. Renditions without resolution metadata are +/// returned unranked when no rendition has any metadata at all (mirrors the JS). +fn by_pixels(renditions: &BTreeMap, max: u32) -> Vec { + let mut within: Vec<(String, u32)> = Vec::new(); + let mut rest: Vec<(String, u32)> = Vec::new(); + + for (name, config) in renditions { + if let (Some(w), Some(h)) = (config.coded_width, config.coded_height) { + let size = w.saturating_mul(h); + if size <= max { + within.push((name.clone(), size)); + } else { + rest.push((name.clone(), size)); + } + } + } + + within.sort_by_key(|b| std::cmp::Reverse(b.1)); + if !within.is_empty() { + return within.into_iter().map(|(n, _)| n).collect(); + } + + rest.sort_by_key(|a| a.1); + if let Some(smallest) = rest.into_iter().next() { + return vec![smallest.0]; + } + + renditions.keys().cloned().collect() +} + +fn by_dimensions(renditions: &BTreeMap, width: Option, height: Option) -> Vec { + let mut within: Vec<(String, u32)> = Vec::new(); + let mut rest: Vec<(String, u32)> = Vec::new(); + + for (name, config) in renditions { + let (Some(w), Some(h)) = (config.coded_width, config.coded_height) else { + continue; + }; + let size = w.saturating_mul(h); + let fits_w = width.is_none_or(|cap| w <= cap); + let fits_h = height.is_none_or(|cap| h <= cap); + if fits_w && fits_h { + within.push((name.clone(), size)); + } else { + rest.push((name.clone(), size)); + } + } + + within.sort_by_key(|b| std::cmp::Reverse(b.1)); + if !within.is_empty() { + return within.into_iter().map(|(n, _)| n).collect(); + } + + rest.sort_by_key(|a| a.1); + if let Some(smallest) = rest.into_iter().next() { + return vec![smallest.0]; + } + + renditions.keys().cloned().collect() +} + +fn by_video_bitrate(renditions: &BTreeMap, max: u64) -> Vec { + let mut within: Vec<(String, u64)> = Vec::new(); + let mut rest: Vec<(String, u64)> = Vec::new(); + for (name, config) in renditions { + if let Some(b) = config.bitrate { + if b <= max { + within.push((name.clone(), b)); + } else { + rest.push((name.clone(), b)); + } + } + } + within.sort_by_key(|b| std::cmp::Reverse(b.1)); + if !within.is_empty() { + return within.into_iter().map(|(n, _)| n).collect(); + } + rest.sort_by_key(|a| a.1); + if let Some(smallest) = rest.into_iter().next() { + return vec![smallest.0]; + } + renditions.keys().cloned().collect() +} + +fn by_audio_bitrate(renditions: &BTreeMap, max: u64) -> Vec { + let mut within: Vec<(String, u64)> = Vec::new(); + let mut rest: Vec<(String, u64)> = Vec::new(); + for (name, config) in renditions { + if let Some(b) = config.bitrate { + if b <= max { + within.push((name.clone(), b)); + } else { + rest.push((name.clone(), b)); + } + } + } + within.sort_by_key(|b| std::cmp::Reverse(b.1)); + if !within.is_empty() { + return within.into_iter().map(|(n, _)| n).collect(); + } + rest.sort_by_key(|a| a.1); + if let Some(smallest) = rest.into_iter().next() { + return vec![smallest.0]; + } + renditions.keys().cloned().collect() +} + +/// With no constraints, prefer the largest resolution then the highest bitrate. +fn best_video(renditions: &BTreeMap) -> String { + renditions + .iter() + .max_by_key(|(_, c)| { + let area = c.coded_width.unwrap_or(0).saturating_mul(c.coded_height.unwrap_or(0)) as u64; + (area, c.bitrate.unwrap_or(0)) + }) + .map(|(n, _)| n.clone()) + .expect("renditions non-empty checked by caller") +} + +fn best_audio(renditions: &BTreeMap) -> String { + renditions + .iter() + .max_by_key(|(_, c)| c.bitrate.unwrap_or(0)) + .map(|(n, _)| n.clone()) + .expect("renditions non-empty checked by caller") +} + +#[cfg(test)] +mod test { + use hang::catalog::{Container, H264, VideoConfig}; + + use super::*; + + fn vid(name: &str, w: u32, h: u32, bitrate: u64) -> (String, VideoConfig) { + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + config.coded_width = Some(w); + config.coded_height = Some(h); + config.bitrate = Some(bitrate); + config.framerate = Some(30.0); + config.container = Container::Legacy; + (name.to_string(), config) + } + + fn map(items: Vec<(String, VideoConfig)>) -> BTreeMap { + BTreeMap::from_iter(items) + } + + #[test] + fn pick_largest_under_width_cap() { + let renditions = map(vec![ + vid("sd", 640, 360, 500_000), + vid("hd", 1280, 720, 2_500_000), + vid("fhd", 1920, 1080, 6_000_000), + ]); + let target = TargetVideo { + width: Some(1280), + ..Default::default() + }; + assert_eq!(select_video(&renditions, &target).as_deref(), Some("hd")); + } + + #[test] + fn pick_largest_under_bitrate_cap() { + let renditions = map(vec![ + vid("sd", 640, 360, 500_000), + vid("hd", 1280, 720, 2_500_000), + vid("fhd", 1920, 1080, 6_000_000), + ]); + let target = TargetVideo { + bitrate: Some(3_000_000), + ..Default::default() + }; + assert_eq!(select_video(&renditions, &target).as_deref(), Some("hd")); + } + + #[test] + fn degrade_to_smallest_over_budget() { + let renditions = map(vec![vid("hd", 1280, 720, 2_500_000), vid("fhd", 1920, 1080, 6_000_000)]); + let target = TargetVideo { + bitrate: Some(100_000), + ..Default::default() + }; + assert_eq!(select_video(&renditions, &target).as_deref(), Some("hd")); + } + + #[test] + fn no_constraints_picks_largest() { + let renditions = map(vec![ + vid("sd", 640, 360, 500_000), + vid("hd", 1280, 720, 2_500_000), + vid("fhd", 1920, 1080, 6_000_000), + ]); + let target = TargetVideo::default(); + assert_eq!(select_video(&renditions, &target).as_deref(), Some("fhd")); + } + + #[test] + fn width_and_bitrate_intersect() { + let renditions = map(vec![ + vid("sd", 640, 360, 500_000), + vid("hd", 1280, 720, 2_500_000), + vid("fhd", 1920, 1080, 6_000_000), + ]); + let target = TargetVideo { + width: Some(1920), + bitrate: Some(1_000_000), + ..Default::default() + }; + // width allows all, bitrate allows only sd. + assert_eq!(select_video(&renditions, &target).as_deref(), Some("sd")); + } +} diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs new file mode 100644 index 000000000..a9a7c8a0f --- /dev/null +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -0,0 +1,118 @@ +use super::Producer; +use super::hang::CatalogExt; + +/// A single video track's catalog rendition, retired on drop. +/// +/// Made via [`Producer::video_track`]. An importer holds one and publishes its +/// rendition through it ([`set`](Self::set), refined in place with +/// [`update`](Self::update)). When the importer drops, the rendition is removed +/// from the shared catalog, so the broadcast catalog stays out of the importer's +/// type while still being published into. +pub struct VideoTrack { + catalog: Producer, + name: String, + /// Whether a config has been published yet, so a lazily-configured importer + /// (e.g. H.264 before its SPS) can hold the handle without a catalog entry, and + /// drop without a spurious removal. + present: bool, +} + +impl VideoTrack { + pub(super) fn new(catalog: Producer, name: impl Into) -> Self { + Self { + catalog, + name: name.into(), + present: false, + } + } + + /// The track name this rendition is keyed by. + pub fn name(&self) -> &str { + &self.name + } + + /// Resolve a timestamp on the broadcast's shared clock (see [`Producer::timestamp`]). + pub fn timestamp(&self, hint: Option) -> crate::Result { + self.catalog.timestamp(hint) + } + + /// Insert or replace the rendition, publishing the catalog. + pub fn set(&mut self, config: hang::catalog::VideoConfig) { + self.catalog.lock().video.renditions.insert(self.name.clone(), config); + self.present = true; + } + + /// Refine the rendition in place (e.g. observed jitter), publishing if present. + pub fn update(&mut self, f: impl FnOnce(&mut hang::catalog::VideoConfig)) { + if !self.present { + return; + } + let mut guard = self.catalog.lock(); + if let Some(config) = guard.video.renditions.get_mut(&self.name) { + f(config); + } + } +} + +impl Drop for VideoTrack { + fn drop(&mut self) { + if self.present { + self.catalog.lock().video.renditions.remove(&self.name); + } + } +} + +/// A single audio track's catalog rendition, retired on drop. +/// +/// The audio counterpart of [`VideoTrack`]; made via [`Producer::audio_track`]. +pub struct AudioTrack { + catalog: Producer, + name: String, + present: bool, +} + +impl AudioTrack { + pub(super) fn new(catalog: Producer, name: impl Into) -> Self { + Self { + catalog, + name: name.into(), + present: false, + } + } + + /// The track name this rendition is keyed by. + pub fn name(&self) -> &str { + &self.name + } + + /// Resolve a timestamp on the broadcast's shared clock (see [`Producer::timestamp`]). + pub fn timestamp(&self, hint: Option) -> crate::Result { + self.catalog.timestamp(hint) + } + + /// Insert or replace the rendition, publishing the catalog. + pub fn set(&mut self, config: hang::catalog::AudioConfig) { + self.catalog.lock().audio.renditions.insert(self.name.clone(), config); + self.present = true; + } + + /// Refine the rendition in place (e.g. a synthesized description or jitter), + /// publishing if present. + pub fn update(&mut self, f: impl FnOnce(&mut hang::catalog::AudioConfig)) { + if !self.present { + return; + } + let mut guard = self.catalog.lock(); + if let Some(config) = guard.audio.renditions.get_mut(&self.name) { + f(config); + } + } +} + +impl Drop for AudioTrack { + fn drop(&mut self) { + if self.present { + self.catalog.lock().audio.renditions.remove(&self.name); + } + } +} diff --git a/rs/moq-mux/src/codec/aac/import.rs b/rs/moq-mux/src/codec/aac/import.rs index 366529a97..d6869aecf 100644 --- a/rs/moq-mux/src/codec/aac/import.rs +++ b/rs/moq-mux/src/codec/aac/import.rs @@ -1,48 +1,27 @@ -use bytes::{Buf, BytesMut}; - use super::Config; use crate::catalog::hang::CatalogExt; +use crate::container::Frame; /// AAC importer. /// /// Initialized from an AudioSpecificConfig blob (variable-length, typically extracted from -/// an MP4 ESDS atom). Each input buffer passed to [`decode`](Self::decode) is published as -/// one hang frame in its own group, so the relay can forward each frame without waiting for -/// a group boundary. The codec's packet loss concealment handles drops. +/// an MP4 ESDS atom), so its catalog is known up front. Each packet passed to +/// [`decode`](Self::decode) is published as one hang frame in its own group, so the relay can +/// forward each frame without waiting for a group boundary. The codec's packet loss +/// concealment handles drops. Build it with [`new`](Self::new), passing the track producer +/// and the [`catalog::Producer`](crate::catalog::Producer) it publishes its rendition into. pub struct Import { - catalog: crate::catalog::Producer, track: crate::container::Producer, - zero: Option, + rendition: crate::catalog::AudioTrack, } impl Import { + /// Publish on an existing track producer, registering the rendition in `catalog`. pub fn new( - broadcast: moq_net::BroadcastProducer, - catalog: crate::catalog::Producer, - config: Config, - ) -> anyhow::Result { - Self::new_with_source( - crate::track_provider::TrackProvider::unique(broadcast, ".aac"), - catalog, - config, - ) - } - - pub fn new_with_track( track: moq_net::TrackProducer, catalog: crate::catalog::Producer, config: Config, - ) -> anyhow::Result { - Self::new_with_source(crate::track_provider::TrackProvider::fixed(track), catalog, config) - } - - fn new_with_source( - mut tracks: crate::track_provider::TrackProvider, - mut catalog: crate::catalog::Producer, - config: Config, - ) -> anyhow::Result { - let track = tracks.create()?; - + ) -> crate::Result { let mut audio_config = hang::catalog::AudioConfig::new( hang::catalog::AAC { profile: config.profile, @@ -52,74 +31,57 @@ impl Import { ); audio_config.container = hang::catalog::Container::Legacy; - tracing::debug!(name = ?track.name, config = ?audio_config, "starting track"); - catalog.lock().audio.renditions.insert(track.name.clone(), audio_config); + tracing::debug!(name = ?track.name(), config = ?audio_config, "starting track"); + + let mut rendition = catalog.audio_track(track.name()); + rendition.set(audio_config); Ok(Self { - catalog, track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), - zero: None, + rendition, }) } - /// Returns a reference to the underlying track producer. - pub fn track(&self) -> &moq_net::TrackProducer { - self.track.track() + /// The MoQ track name this importer publishes on. + pub fn name(&self) -> &str { + self.track.name() + } + + /// A watch-only handle to this track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + self.track.track().demand() + } + + /// Refine the single audio rendition in place, republishing the catalog. + /// + /// The TS importer uses this to set the synthesized `description` and an + /// audio-burst `jitter` once it knows them. + pub(crate) fn update_rendition(&mut self, f: impl FnOnce(&mut hang::catalog::AudioConfig)) { + self.rendition.update(f); } /// Finish the track, flushing the current group. - pub fn finish(&mut self) -> anyhow::Result<()> { + pub fn finish(&mut self) -> crate::Result<()> { self.track.finish()?; Ok(()) } /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { + pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; Ok(()) } - pub fn decode(&mut self, buf: &mut T, pts: Option) -> anyhow::Result<()> { - let pts = self.pts(pts)?; - - // Collect the input into a contiguous Bytes payload. - let mut payload = BytesMut::with_capacity(buf.remaining()); - while buf.has_remaining() { - let chunk = buf.chunk(); - payload.extend_from_slice(chunk); - let len = chunk.len(); - buf.advance(len); - } - - // Each frame is its own group so the relay can forward it immediately. - // The codec's packet loss concealment handles drops. - let frame = crate::container::Frame { - timestamp: pts, - payload: payload.freeze(), + /// Publish one AAC packet as its own group, stamping `pts` or a wall clock when absent. + pub fn decode(&mut self, frame: &[u8], pts: Option) -> crate::Result<()> { + let timestamp = self.rendition.timestamp(pts)?; + self.track.write(Frame { + timestamp, + payload: bytes::Bytes::copy_from_slice(frame), keyframe: true, - }; - - self.track.write(frame)?; + duration: None, + })?; self.track.finish_group()?; - Ok(()) } - - fn pts(&mut self, hint: Option) -> anyhow::Result { - if let Some(pts) = hint { - return Ok(pts); - } - - let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); - Ok(crate::container::Timestamp::from_micros( - zero.elapsed().as_micros() as u64 - )?) - } -} - -impl Drop for Import { - fn drop(&mut self) { - tracing::debug!(name = ?self.track.name, "ending track"); - self.catalog.lock().audio.renditions.remove(&self.track.name); - } } diff --git a/rs/moq-mux/src/codec/aac/mod.rs b/rs/moq-mux/src/codec/aac/mod.rs index be11cd25c..0360f6c82 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -7,9 +7,30 @@ mod import; pub use import::*; -use anyhow::Context; use bytes::{Buf, Bytes}; +/// AAC parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("AudioSpecificConfig must be at least 2 bytes")] + ConfigTooShort, + + #[error("extended audioObjectType requires 2 additional bytes")] + ExtendedConfigTooShort, + + #[error("AudioSpecificConfig incomplete")] + IncompleteConfig, + + #[error("explicit sample rate requires 3 additional bytes")] + ExplicitSampleRateTooShort, + + #[error("unsupported sample rate index: {0}")] + UnsupportedSampleRateIndex(u8), +} + +pub type Result = std::result::Result; + /// Typed AAC configuration mirroring the relevant fields of an /// AudioSpecificConfig. pub struct Config { @@ -24,8 +45,10 @@ impl Config { /// Handles basic formats (object_type < 31), extended formats /// (object_type == 31), and explicit sample rates (freq_index == 15). /// Any SBR/PS extension bytes after the core fields are consumed. - pub fn parse(buf: &mut T) -> anyhow::Result { - anyhow::ensure!(buf.remaining() >= 2, "AudioSpecificConfig must be at least 2 bytes"); + pub fn parse(buf: &mut T) -> Result { + if buf.remaining() < 2 { + return Err(Error::ConfigTooShort); + } // Read first byte let b0 = buf.get_u8(); @@ -33,10 +56,9 @@ impl Config { let freq_index; let (profile, sample_rate, channel_count) = if object_type == 31 { - anyhow::ensure!( - buf.remaining() >= 2, - "extended audioObjectType requires 2 additional bytes" - ); + if buf.remaining() < 2 { + return Err(Error::ExtendedConfigTooShort); + } // Extended format: next 6 bits are the extended object_type (32-63). // Bits 5-7 of b0 are the first 3 bits of extended object_type. let b_ext = buf.get_u8(); @@ -49,7 +71,9 @@ impl Config { let channel_config_high = b_ext & 0x01; // Read next byte for rest of channelConfiguration. - anyhow::ensure!(buf.remaining() >= 1, "AudioSpecificConfig incomplete"); + if buf.remaining() < 1 { + return Err(Error::IncompleteConfig); + } let b1 = buf.get_u8(); // Bits 5-7 of b1 are the remaining 3 bits of channelConfiguration. let channel_config = (channel_config_high << 3) | ((b1 >> 5) & 0x07); @@ -66,7 +90,9 @@ impl Config { // Standard format: bits 5-7 of b0 are first 3 bits of freq_index. let mut freq_index_local = (b0 & 0x07) << 1; - anyhow::ensure!(buf.remaining() >= 1, "AudioSpecificConfig incomplete"); + if buf.remaining() < 1 { + return Err(Error::IncompleteConfig); + } let b1 = buf.get_u8(); // Complete frequency index (bit 7 of b1 is bit 0 of freq_index). @@ -140,13 +166,15 @@ impl Config { } } -fn sample_rate_from_index(freq_index: u8, buf: &mut T) -> anyhow::Result { +fn sample_rate_from_index(freq_index: u8, buf: &mut T) -> Result { const SAMPLE_RATES: [u32; 13] = [ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, ]; if freq_index == 15 { - anyhow::ensure!(buf.remaining() >= 3, "explicit sample rate requires 3 additional bytes"); + if buf.remaining() < 3 { + return Err(Error::ExplicitSampleRateTooShort); + } let rate_bytes = [buf.get_u8(), buf.get_u8(), buf.get_u8()]; return Ok(((rate_bytes[0] as u32) << 16) | ((rate_bytes[1] as u32) << 8) | (rate_bytes[2] as u32)); } @@ -154,7 +182,7 @@ fn sample_rate_from_index(freq_index: u8, buf: &mut T) -> anyhow::Result SAMPLE_RATES .get(freq_index as usize) .copied() - .context("unsupported sample rate index") + .ok_or(Error::UnsupportedSampleRateIndex(freq_index)) } /// Map an AAC `channel_config` (ISO 14496-3 Table 1.19) to its real channel count. diff --git a/rs/moq-mux/src/codec/ac3.rs b/rs/moq-mux/src/codec/ac3.rs index fd4a34686..a2b792acd 100644 --- a/rs/moq-mux/src/codec/ac3.rs +++ b/rs/moq-mux/src/codec/ac3.rs @@ -25,18 +25,26 @@ const CHANNELS: [u32; 8] = [2, 1, 2, 3, 3, 4, 4, 5]; const SAMPLES_PER_FRAME: u64 = 1536; /// Parse an AC-3 sync frame header from the start of `data` (needs >= 7 bytes). -pub(crate) fn parse_header(data: &[u8]) -> anyhow::Result { - anyhow::ensure!(data.len() >= 7, "AC-3 header needs 7 bytes"); - anyhow::ensure!(data[0] == 0x0B && data[1] == 0x77, "missing AC-3 sync word"); +pub(crate) fn parse_header(data: &[u8]) -> legacy::Result { + if data.len() < 7 { + return Err(legacy::Error::Ac3HeaderTooShort); + } + if !(data[0] == 0x0B && data[1] == 0x77) { + return Err(legacy::Error::Ac3MissingSyncWord); + } let fscod = data[4] >> 6; let frmsizecod = (data[4] & 0x3F) as usize; - anyhow::ensure!(frmsizecod <= 37, "invalid AC-3 frame size code"); + if frmsizecod > 37 { + return Err(legacy::Error::Ac3InvalidFrameSizeCode); + } let bitrate_kbps = BITRATE[frmsizecod >> 1] as usize; // bsid > 8 is E-AC-3 or a low-sample-rate variant, neither parsed here. let bsid = data[5] >> 3; - anyhow::ensure!(bsid <= 8, "unsupported AC-3 bsid {bsid}"); + if bsid > 8 { + return Err(legacy::Error::Ac3UnsupportedBsid(bsid)); + } // At 44.1 kHz the frame doesn't divide evenly, so the low frmsizecod bit // selects the padded size (A/52 Table 5.18). @@ -44,7 +52,7 @@ pub(crate) fn parse_header(data: &[u8]) -> anyhow::Result { 0b00 => (48000, 4 * bitrate_kbps), 0b01 => (44100, 2 * (320 * bitrate_kbps / 147 + (frmsizecod & 1))), 0b10 => (32000, 6 * bitrate_kbps), - _ => anyhow::bail!("reserved AC-3 sample-rate code"), + _ => return Err(legacy::Error::Ac3ReservedSampleRate), }; // acmod decides which mix-level fields precede lfeon; skip them bit by bit. diff --git a/rs/moq-mux/src/codec/annexb.rs b/rs/moq-mux/src/codec/annexb.rs index 8758ece65..33b2041c3 100644 --- a/rs/moq-mux/src/codec/annexb.rs +++ b/rs/moq-mux/src/codec/annexb.rs @@ -1,8 +1,75 @@ -use anyhow::Context; use bytes::{Buf, Bytes, BytesMut}; pub const START_CODE: Bytes = Bytes::from_static(&[0, 0, 0, 1]); +/// Annex B parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("missing Annex B start code")] + MissingStartCode, + + #[error("invalid Annex B start code")] + InvalidStartCode, + + #[error("invalid avc1/hvc1 length size {0}")] + InvalidLengthSize(usize), + + #[error("truncated length-prefixed NAL unit")] + Truncated, +} + +pub type Result = std::result::Result; + +/// Convert a length-prefixed NALU payload (avc1 / hvc1 wire shape) to Annex-B, +/// optionally prepending `prefix` bytes (typically VPS/SPS/PPS NAL units already +/// in Annex-B form, for keyframe parameter-set injection). +pub fn from_length_prefixed(payload: &[u8], length_size: usize, prefix: Option<&[u8]>) -> Result { + if !(1..=4).contains(&length_size) { + return Err(Error::InvalidLengthSize(length_size)); + } + + let mut out = BytesMut::with_capacity(payload.len() + prefix.map(|p| p.len()).unwrap_or(0) + 16); + if let Some(p) = prefix { + out.extend_from_slice(p); + } + + let mut pos = 0; + while pos < payload.len() { + let after_prefix = pos.checked_add(length_size).ok_or(Error::Truncated)?; + if payload.len() < after_prefix { + return Err(Error::Truncated); + } + let mut len = 0usize; + for byte in &payload[pos..after_prefix] { + len = (len << 8) | (*byte as usize); + } + let after_nal = after_prefix.checked_add(len).ok_or(Error::Truncated)?; + if payload.len() < after_nal { + return Err(Error::Truncated); + } + out.extend_from_slice(&START_CODE); + out.extend_from_slice(&payload[after_prefix..after_nal]); + pos = after_nal; + } + + Ok(out.freeze()) +} + +/// Concatenate `start_code | nal` for every NAL in `nals` and freeze the +/// result. Used to build a keyframe parameter-set prefix for an Annex-B +/// elementary stream. +pub fn build_prefix<'a, I: IntoIterator>(nals: I) -> Bytes { + let nals: Vec<&Bytes> = nals.into_iter().collect(); + let total: usize = nals.iter().map(|n| n.len() + START_CODE.len()).sum(); + let mut out = BytesMut::with_capacity(total); + for nal in nals { + out.extend_from_slice(&START_CODE); + out.extend_from_slice(nal); + } + out.freeze() +} + /// Append `nal` to `set` unless a byte-identical entry is already present, /// preserving insertion order. Returns true if it was added. /// @@ -51,7 +118,7 @@ impl<'a, T: Buf + AsRef<[u8]> + 'a> NalIterator<'a, T> { /// Assume the buffer ends with a NAL unit and flush it. /// This is more efficient because we cache the last "start" code position. - pub fn flush(self) -> anyhow::Result> { + pub fn flush(self) -> Result> { let start = match self.start { Some(start) => start, None => { @@ -70,7 +137,7 @@ impl<'a, T: Buf + AsRef<[u8]> + 'a> NalIterator<'a, T> { } impl<'a, T: Buf + AsRef<[u8]> + 'a> Iterator for NalIterator<'a, T> { - type Item = anyhow::Result; + type Item = Result; fn next(&mut self) -> Option { let start = match self.start { @@ -116,21 +183,22 @@ pub fn length_prefixed_to_annexb(mut data: &[u8], length_size: usize, out: &mut } // Return the size of the start code at the start of the buffer. -pub fn after_start_code(b: &[u8]) -> anyhow::Result> { +pub fn after_start_code(b: &[u8]) -> Result> { if b.len() < 3 { return Ok(None); } // NOTE: We have to check every byte, so the `find_start_code` optimization doesn't matter. - anyhow::ensure!(b[0] == 0, "missing Annex B start code"); - anyhow::ensure!(b[1] == 0, "missing Annex B start code"); + if b[0] != 0 || b[1] != 0 { + return Err(Error::MissingStartCode); + } match b[2] { 0 if b.len() < 4 => Ok(None), - 0 if b[3] != 1 => anyhow::bail!("missing Annex B start code"), + 0 if b[3] != 1 => Err(Error::MissingStartCode), 0 => Ok(Some(4)), 1 => Ok(Some(3)), - _ => anyhow::bail!("invalid Annex B start code"), + _ => Err(Error::InvalidStartCode), } } @@ -148,58 +216,61 @@ pub fn find_start_code(b: &[u8]) -> Option<(usize, usize)> { } } -/// Convert a length-prefixed NALU payload (avc1 / hvc1 wire shape) to Annex-B, -/// optionally prepending `prefix` bytes (typically VPS/SPS/PPS NAL units already -/// in Annex-B form, for keyframe parameter-set injection). -pub fn from_length_prefixed(payload: &[u8], length_size: usize, prefix: Option<&[u8]>) -> anyhow::Result { - anyhow::ensure!( - (1..=4).contains(&length_size), - "invalid avc1/hvc1 length size {length_size}" - ); +#[cfg(test)] +mod tests { + use super::*; - let mut out = BytesMut::with_capacity(payload.len() + prefix.map(|p| p.len()).unwrap_or(0) + 16); - if let Some(p) = prefix { - out.extend_from_slice(p); - } + // Tests for from_length_prefixed - converts avc1/hvc1 to Annex-B, + // with optional SPS/PPS prefix injection on keyframes. - let mut pos = 0; - while pos < payload.len() { - let after_prefix = pos - .checked_add(length_size) - .context("truncated length-prefixed NAL unit")?; - anyhow::ensure!(payload.len() >= after_prefix, "truncated length-prefixed NAL unit"); - let mut len = 0usize; - for byte in &payload[pos..after_prefix] { - len = (len << 8) | (*byte as usize); - } - let after_nal = after_prefix - .checked_add(len) - .context("truncated length-prefixed NAL unit")?; - anyhow::ensure!(payload.len() >= after_nal, "truncated length-prefixed NAL unit"); - out.extend_from_slice(&START_CODE); - out.extend_from_slice(&payload[after_prefix..after_nal]); - pos = after_nal; + #[test] + fn from_length_prefixed_no_prefix() { + // One 4-byte length, then 2-byte NAL `0x65 0x88` (an H.264 IDR slice). + let payload = &[0, 0, 0, 2, 0x65, 0x88]; + let out = from_length_prefixed(payload, 4, None).unwrap(); + assert_eq!(out.as_ref(), &[0, 0, 0, 1, 0x65, 0x88]); } - Ok(out.freeze()) -} + #[test] + fn from_length_prefixed_with_prefix_injects_verbatim() { + // SPS+PPS prefix built by `build_prefix` (start_code + sps_nal + start_code + pps_nal). + let sps = Bytes::from_static(&[0x67, 0x42, 0xc0, 0x1f]); + let pps = Bytes::from_static(&[0x68, 0xce, 0x3c, 0x80]); + let prefix = build_prefix([&sps, &pps]); + assert_eq!( + prefix.as_ref(), + &[ + 0, 0, 0, 1, 0x67, 0x42, 0xc0, 0x1f, // start_code + SPS + 0, 0, 0, 1, 0x68, 0xce, 0x3c, 0x80, // start_code + PPS + ] + ); -/// Concatenate `start_code | nal` for every NAL in `nals` and freeze the result. -/// Used to build a keyframe parameter-set prefix for an Annex-B elementary stream. -pub fn build_prefix<'a, I: IntoIterator>(nals: I) -> Bytes { - let nals: Vec<&Bytes> = nals.into_iter().collect(); - let total: usize = nals.iter().map(|n| n.len() + START_CODE.len()).sum(); - let mut out = BytesMut::with_capacity(total); - for nal in nals { - out.extend_from_slice(&START_CODE); - out.extend_from_slice(nal); + // One length-prefixed IDR slice. + let payload = &[0, 0, 0, 2, 0x65, 0x88]; + let out = from_length_prefixed(payload, 4, Some(&prefix)).unwrap(); + + // Output must start with the prefix byte-for-byte, then the slice in Annex-B form. + assert_eq!(&out[..prefix.len()], prefix.as_ref()); + assert_eq!(&out[prefix.len()..], &[0, 0, 0, 1, 0x65, 0x88]); } - out.freeze() -} -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn from_length_prefixed_multiple_nals_with_prefix() { + // Two NALs in one frame: AUD then IDR slice. Prefix gets prepended once. + let prefix = build_prefix([&Bytes::from_static(&[0x67, 0x42])]); + let payload = &[ + 0, 0, 0, 2, 0x09, 0x10, // AUD + 0, 0, 0, 2, 0x65, 0x88, // IDR slice + ]; + let out = from_length_prefixed(payload, 4, Some(&prefix)).unwrap(); + + // Single prefix followed by both NALs in Annex-B order. + let mut expected = Vec::new(); + expected.extend_from_slice(&prefix); + expected.extend_from_slice(&[0, 0, 0, 1, 0x09, 0x10]); // AUD + expected.extend_from_slice(&[0, 0, 0, 1, 0x65, 0x88]); // IDR + assert_eq!(out.as_ref(), expected.as_slice()); + } #[test] fn length_prefixed_to_annexb_rewrites_prefixes() { diff --git a/rs/moq-mux/src/codec/av1/import.rs b/rs/moq-mux/src/codec/av1/import.rs index 8ea9f9fbb..7d5632652 100644 --- a/rs/moq-mux/src/codec/av1/import.rs +++ b/rs/moq-mux/src/codec/av1/import.rs @@ -1,67 +1,108 @@ -use crate::container::jitter::Jitter; - -use anyhow::Context; -use bytes::BytesMut; -use bytes::{Buf, Bytes}; +//! AV1 importer. +//! +//! Publishes raw AV1 (OBU-framed, inline sequence headers) on a single moq +//! track and resolves the catalog rendition. The codec config comes from the +//! sequence header the splitter packages into the first keyframe (scanned out of +//! the frame here), or from an av1C record handed to +//! [`initialize`](Import::initialize). A keyframe that can't be configured is an +//! error; non-keyframes before the first config are written through to the +//! producer, which reports [`MissingKeyframe`](crate::container::MissingKeyframe) +//! for a mid-stream join. OBU byte parsing lives in [`Split`](super::Split); this type is a +//! pure frame publisher that whoever owns the split drives via [`decode`](Import::decode). + +use bytes::Bytes; use scuffle_av1::seq::SequenceHeaderObu; +use scuffle_av1::{ObuHeader, ObuType}; -/// A decoder for AV1 with inline sequence headers. -pub struct Import { - // Where new media tracks come from. - tracks: crate::track_provider::TrackProvider, - - // The catalog being produced. - catalog: crate::catalog::Producer, - - // The track being produced. - track: Option>, +use super::Error; +use super::split::ObuIterator; +use crate::Result; +use crate::catalog::hang::CatalogExt; +use crate::container::Frame; +use crate::container::jitter::Jitter; - // Whether the track has been initialized. +/// A pure-publisher importer for AV1 with inline sequence headers. +/// +/// Build it with [`new`](Self::new), passing the track producer and the +/// [`catalog::Producer`](crate::catalog::Producer) it publishes into, and feed it +/// frames a [`Split`](super::Split) produced via [`decode`](Self::decode). The +/// catalog rendition fills in lazily once the config is known. +pub struct Import { + track: crate::container::Producer, + rendition: crate::catalog::VideoTrack, config: Option, - - // The current frame being built. - current: Frame, - - // Used to compute wall clock timestamps if needed. - zero: Option, - - // Tracks the minimum frame duration and updates the catalog `jitter` field. + last_seq: Option, jitter: Jitter, } -#[derive(Default)] -struct Frame { - chunks: BytesMut, - contains_keyframe: bool, - contains_frame: bool, -} - -impl Import { - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { +impl Import { + /// Publish on an existing track producer, registering the rendition in `catalog`. + pub fn new(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { + let rendition = catalog.video_track(track.name()); Self { - tracks: crate::track_provider::TrackProvider::unique(broadcast, ".av01"), - catalog, - track: None, + track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), + rendition, config: None, - current: Default::default(), - zero: None, + last_seq: None, jitter: Jitter::new(), } } - pub fn new_with_track(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { - Self { - tracks: crate::track_provider::TrackProvider::fixed(track), - catalog, - track: None, - config: None, - current: Default::default(), - zero: None, - jitter: Jitter::new(), + /// Resolve the codec config from a sequence header / av1C and other metadata. + /// + /// - **av1C** (leading `0x81` marker): the buffer is parsed as an + /// AV1CodecConfigurationRecord, which resolves the config. + /// - **raw OBUs**: any sequence header resolves the config. + /// + /// Optional, since the importer also self-initializes from the first keyframe. + /// The buffer is *not* consumed: the dispatcher-owned [`Split`](super::Split) + /// consumes it (seeding the sequence header so it prefixes the first keyframe). + pub fn initialize(&mut self, buf: &[u8]) -> Result<()> { + let data = buf; + + // av1C box starts with 0x81 (marker=1, version=1) per ISO/IEC 14496-15. Only the + // fixed 4-byte header is read here, so don't gate on a larger size or a short + // out-of-band record falls through to raw-OBU scanning and leaves the config unset. + if data.len() >= 4 && data[0] == 0x81 { + self.init_from_av1c(data)?; + return Ok(()); } + + // Raw OBUs: resolve the config from any sequence header. + if let Some(seq) = find_sequence_header(data) { + self.configure_from_seq(&seq)?; + } + Ok(()) + } + + fn init_from_av1c(&mut self, data: &[u8]) -> Result<()> { + let seq_profile = (data[1] >> 5) & 0x07; + let seq_level_idx = data[1] & 0x1F; + let tier = ((data[2] >> 7) & 0x01) == 1; + let high_bitdepth = ((data[2] >> 6) & 0x01) == 1; + let twelve_bit = ((data[2] >> 5) & 0x01) == 1; + + // Resolution is unknown from av1C; it's filled when the first sequence header arrives. + let mut config = hang::catalog::VideoConfig::new(hang::catalog::AV1 { + profile: seq_profile, + level: seq_level_idx, + tier: if tier { 'H' } else { 'M' }, + bitdepth: super::bitdepth(twelve_bit, high_bitdepth), + mono_chrome: ((data[2] >> 4) & 0x01) == 1, + chroma_subsampling_x: ((data[2] >> 3) & 0x01) == 1, + chroma_subsampling_y: ((data[2] >> 2) & 0x01) == 1, + chroma_sample_position: data[2] & 0x03, + color_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + full_range: false, + }); + config.container = hang::catalog::Container::Legacy; + self.apply_config(config); + Ok(()) } - fn init(&mut self, seq_header: &SequenceHeaderObu) -> anyhow::Result<()> { + fn init(&mut self, seq_header: &SequenceHeaderObu) -> Result<()> { let mut config = hang::catalog::VideoConfig::new(hang::catalog::AV1 { profile: seq_header.seq_profile, level: seq_header @@ -92,46 +133,18 @@ impl Import { config.coded_width = Some(seq_header.max_frame_width as u32); config.coded_height = Some(seq_header.max_frame_height as u32); config.container = hang::catalog::Container::Legacy; - - if let Some(old) = &self.config - && old == &config - { - return Ok(()); - } - - if self.track.is_some() && self.tracks.is_fixed() { - anyhow::bail!("fixed track cannot be reconfigured"); - } - - if let Some(track) = self.track.take() { - tracing::debug!(name = ?track.name, "reinitializing track"); - self.catalog.lock().video.renditions.remove(&track.name); - } - - let track = self.tracks.create()?; - tracing::debug!(name = ?track.name, ?config, "starting track"); - self.catalog - .lock() - .video - .renditions - .insert(track.name.clone(), config.clone()); - - self.config = Some(config); - self.track = Some(crate::container::Producer::new( - track, - crate::catalog::hang::Container::Legacy, - )); - + self.apply_config(config); Ok(()) } - /// Initialize with minimal config if sequence header parsing fails - fn init_minimal(&mut self) -> anyhow::Result<()> { + /// Minimal config when sequence-header parsing fails, so the stream can still + /// flow (the catalog just won't carry full codec info). + fn init_minimal(&mut self) -> Result<()> { let mut config = hang::catalog::VideoConfig::new(hang::catalog::AV1 { - profile: 0, // Main profile - level: 0, // Unknown - tier: 'M', // Main tier - bitdepth: 8, // Assume 8-bit + profile: 0, + level: 0, + tier: 'M', + bitdepth: 8, mono_chrome: false, chroma_subsampling_x: true, // 4:2:0 chroma_subsampling_y: true, @@ -142,391 +155,112 @@ impl Import { full_range: false, }); config.container = hang::catalog::Container::Legacy; - - if self.track.is_some() && self.tracks.is_fixed() { - anyhow::bail!("fixed track cannot be reconfigured"); - } - - let track = self.tracks.create()?; - tracing::debug!(name = ?track.name, "starting track with minimal config"); - self.catalog - .lock() - .video - .renditions - .insert(track.name.clone(), config.clone()); - - self.config = Some(config); - self.track = Some(crate::container::Producer::new( - track, - crate::catalog::hang::Container::Legacy, - )); - + self.apply_config(config); Ok(()) } - /// Initialize the decoder with sequence header and other metadata OBUs. - pub fn initialize>(&mut self, buf: &mut T) -> anyhow::Result<()> { - let data = buf.as_ref(); - - // Handle av1C format (MP4/container initialization) - // av1C box starts with 0x81 (marker=1, version=1) per ISO/IEC 14496-15 - if data.len() >= 4 && data[0] == 0x81 && data.len() >= 16 { - self.init_from_av1c(data)?; - buf.advance(data.len()); - return Ok(()); - } - - // Handle raw OBU format - let mut obus = ObuIterator::new(buf); - while let Some(obu) = obus.next().transpose()? { - self.decode_obu(obu, None)?; + /// Apply a resolved config, updating the catalog rendition in place. + /// + /// A changed config just re-mirrors the rendition; there are no fixed tracks + /// to reject a reconfiguration. + fn apply_config(&mut self, config: hang::catalog::VideoConfig) { + if self.config.as_ref() == Some(&config) { + return; } - - if let Some(obu) = obus.flush()? { - self.decode_obu(obu, None)?; - } - - Ok(()) + tracing::debug!(name = ?self.track.name(), ?config, "starting track"); + self.rendition.set(config.clone()); + self.config = Some(config); } - fn init_from_av1c(&mut self, data: &[u8]) -> anyhow::Result<()> { - // Parse av1C box structure - let seq_profile = (data[1] >> 5) & 0x07; - let seq_level_idx = data[1] & 0x1F; - let tier = ((data[2] >> 7) & 0x01) == 1; - let high_bitdepth = ((data[2] >> 6) & 0x01) == 1; - let twelve_bit = ((data[2] >> 5) & 0x01) == 1; - - // Resolution unknown from av1C - will be updated when first sequence header arrives - let mut config = hang::catalog::VideoConfig::new(hang::catalog::AV1 { - profile: seq_profile, - level: seq_level_idx, - tier: if tier { 'H' } else { 'M' }, - bitdepth: super::bitdepth(twelve_bit, high_bitdepth), - mono_chrome: ((data[2] >> 4) & 0x01) == 1, - chroma_subsampling_x: ((data[2] >> 3) & 0x01) == 1, - chroma_subsampling_y: ((data[2] >> 2) & 0x01) == 1, - chroma_sample_position: data[2] & 0x03, - color_primaries: 1, - transfer_characteristics: 1, - matrix_coefficients: 1, - full_range: false, - }); - config.container = hang::catalog::Container::Legacy; - - if let Some(old) = &self.config - && old == &config - { + /// Resolve the config from a sequence-header OBU, falling back to a minimal + /// config if it fails to parse. + fn configure_from_seq(&mut self, seq_obu: &Bytes) -> Result<()> { + if self.last_seq.as_ref() == Some(seq_obu) { return Ok(()); } + self.last_seq = Some(seq_obu.clone()); - if self.track.is_some() && self.tracks.is_fixed() { - anyhow::bail!("fixed track cannot be reconfigured"); - } + let mut reader = &seq_obu[..]; + let header = ObuHeader::parse(&mut reader)?; + let payload_offset = seq_obu.len() - reader.len(); - if let Some(track) = self.track.take() { - self.catalog.lock().video.renditions.remove(&track.name); + match SequenceHeaderObu::parse(header, &mut &seq_obu[payload_offset..]) { + Ok(seq_header) => self.init(&seq_header), + Err(_) if self.config.is_none() => { + tracing::debug!("sequence header parse failed, using minimal config"); + self.init_minimal() + } + Err(_) => Ok(()), } - - let track = self.tracks.create()?; - self.catalog - .lock() - .video - .renditions - .insert(track.name.clone(), config.clone()); - - self.config = Some(config); - self.track = Some(crate::container::Producer::new( - track, - crate::catalog::hang::Container::Legacy, - )); - - Ok(()) } - /// Returns a reference to the underlying track producer. - pub fn track(&self) -> anyhow::Result<&moq_net::TrackProducer> { - Ok(self.track.as_ref().context("not initialized")?.track()) + /// A watch-only handle to this track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + self.track.track().demand() } - /// Decode as much data as possible from the given buffer. - pub fn decode_stream>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - let obus = ObuIterator::new(buf); - - for obu in obus { - // Generate PTS for each OBU to avoid reusing same timestamp - let pts = self.pts(pts)?; - self.decode_obu(obu?, Some(pts))?; - } - + /// Finish the track, flushing the current group. + pub fn finish(&mut self) -> Result<()> { + self.track.finish()?; Ok(()) } - /// Decode all data in the buffer, assuming the buffer contains (the rest of) a frame. - pub fn decode_frame>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - let pts = self.pts(pts)?; - let mut obus = ObuIterator::new(buf); - - while let Some(obu) = obus.next().transpose()? { - self.decode_obu(obu, Some(pts))?; - } - - if let Some(obu) = obus.flush()? { - self.decode_obu(obu, Some(pts))?; - } - - self.maybe_start_frame(Some(pts))?; - + /// Close the current group and open the next one at `sequence`. + pub fn seek(&mut self, sequence: u64) -> Result<()> { + self.track.seek(sequence)?; Ok(()) } - fn decode_obu(&mut self, obu_data: Bytes, pts: Option) -> anyhow::Result<()> { - anyhow::ensure!(!obu_data.is_empty(), "OBU is too short"); - - // Parse OBU header - this consumes header + extension + LEB128 size - let mut reader = &obu_data[..]; - let header = scuffle_av1::ObuHeader::parse(&mut reader)?; - - // Calculate payload offset by seeing how much the parser consumed - let payload_offset = obu_data.len() - reader.len(); - - // Match on the ObuType enum directly - use scuffle_av1::ObuType; - match header.obu_type { - ObuType::SequenceHeader => { - match SequenceHeaderObu::parse(header, &mut &obu_data[payload_offset..]) { - Ok(seq_header) => { - self.init(&seq_header)?; - } - Err(_) => { - // Use minimal config so stream can work (catalog won't have full info) - if self.track.is_none() { - tracing::debug!("Sequence header parsing failed, initializing with minimal config"); - self.init_minimal()?; - } - } - } - - self.current.contains_keyframe = true; - } - ObuType::TemporalDelimiter => { - self.maybe_start_frame(pts)?; + /// Write split frames to the track, resolving the config from the first + /// keyframe's inline sequence header and refining the catalog jitter. + fn write_frames(&mut self, frames: impl IntoIterator) -> Result<()> { + for frame in frames { + if frame.keyframe + && let Some(seq) = find_sequence_header(&frame.payload) + { + self.configure_from_seq(&seq)?; } - ObuType::FrameHeader | ObuType::Frame => { - let is_keyframe = if obu_data.len() > payload_offset { - let data = &obu_data[payload_offset..]; - if data.is_empty() { - false - } else { - let first_byte = data[0]; - - let show_existing_frame = (first_byte >> 7) & 1; - - if show_existing_frame == 1 { - self.current.contains_keyframe - } else { - let frame_type = (first_byte >> 5) & 0b11; - frame_type == 0 - } - } - } else { - tracing::warn!( - "Frame OBU too short: {} bytes (payload_offset={})", - obu_data.len(), - payload_offset - ); - false - }; - - if is_keyframe || self.current.contains_keyframe { - self.current.contains_keyframe = true; - } - - self.current.contains_frame = true; - } - ObuType::Metadata => { - self.maybe_start_frame(pts)?; + // A keyframe we couldn't configure (no sequence header) is undecodable. + if frame.keyframe && self.config.is_none() { + return Err(Error::MissingSequenceHeader.into()); } - ObuType::TileGroup | ObuType::TileList => { - self.current.contains_frame = true; - } - _ => { - // Other OBU types - just include them - } - } - - tracing::trace!(?header.obu_type, "parsed OBU"); - - self.current.chunks.extend_from_slice(&obu_data); - Ok(()) - } - - fn maybe_start_frame(&mut self, pts: Option) -> anyhow::Result<()> { - if !self.current.contains_frame { - return Ok(()); - } - - let track = self - .track - .as_mut() - .context("expected sequence header before any frames")?; - let pts = pts.context("missing timestamp")?; + let pts = frame.timestamp; + // A pre-keyframe delta has no group to anchor it: the producer returns + // MissingKeyframe, which a caller joining mid-stream skips. + self.track.write(frame)?; - let payload = std::mem::take(&mut self.current.chunks).freeze(); - - let frame = crate::container::Frame { - timestamp: pts, - payload, - keyframe: self.current.contains_keyframe, - }; - - track.write(frame)?; - - if let Some(jitter) = self.jitter.observe(pts) - && let Some(c) = self.catalog.lock().video.renditions.get_mut(&track.name) - { - c.jitter = Some(jitter); + if let Some(jitter) = self.jitter.observe(pts) { + self.rendition + .update(|c| c.jitter = moq_net::Time::try_from(jitter).ok()); + } } - - self.current.contains_keyframe = false; - self.current.contains_frame = false; - - Ok(()) - } - - /// Finish the track, flushing the current group. - pub fn finish(&mut self) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.finish()?; - Ok(()) - } - - /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.seek(sequence)?; Ok(()) } - pub fn is_initialized(&self) -> bool { - self.track.is_some() - } - - fn pts(&mut self, hint: Option) -> anyhow::Result { - if let Some(pts) = hint { - return Ok(pts); - } - - let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); - Ok(crate::container::Timestamp::from_micros( - zero.elapsed().as_micros() as u64 - )?) - } -} - -impl Drop for Import { - fn drop(&mut self) { - if let Some(track) = self.track.take() { - tracing::debug!(name = ?track.name, "ending track"); - self.catalog.lock().video.renditions.remove(&track.name); - } + /// Publish split frames, resolving the config from the first keyframe's inline + /// sequence header and refining the catalog jitter. + pub fn decode(&mut self, frames: impl IntoIterator) -> Result<()> { + self.write_frames(frames) } } -/// Iterator over AV1 Open Bitstream Units (OBUs) -struct ObuIterator<'a, T: Buf + AsRef<[u8]> + 'a> { - buf: &'a mut T, +fn is_sequence_header(obu: &[u8]) -> bool { + let mut reader = obu; + ObuHeader::parse(&mut reader) + .map(|h| h.obu_type == ObuType::SequenceHeader) + .unwrap_or(false) } -impl<'a, T: Buf + AsRef<[u8]> + 'a> ObuIterator<'a, T> { - pub fn new(buf: &'a mut T) -> Self { - Self { buf } - } - - pub fn flush(self) -> anyhow::Result> { - let remaining = self.buf.remaining(); - if remaining == 0 { - return Ok(None); +/// Find the first sequence-header OBU in a payload, if any. +fn find_sequence_header(payload: &[u8]) -> Option { + let mut buf = Bytes::copy_from_slice(payload); + let mut obus = ObuIterator::new(&mut buf); + while let Some(Ok(obu)) = obus.next() { + if is_sequence_header(&obu) { + return Some(obu); } - - let obu = self.buf.copy_to_bytes(remaining); - Ok(Some(obu)) - } -} - -impl<'a, T: Buf + AsRef<[u8]> + 'a> Iterator for ObuIterator<'a, T> { - type Item = anyhow::Result; - - fn next(&mut self) -> Option { - if self.buf.remaining() == 0 { - return None; - } - - // Parse OBU header to get size - let data = self.buf.as_ref(); - if data.is_empty() { - return None; - } - - // OBU header format: - // - obu_forbidden_bit (1) - // - obu_type (4) - // - obu_extension_flag (1) - // - obu_has_size_field (1) - // - obu_reserved_1bit (1) - - let header = data[0]; - let has_extension = (header >> 2) & 1 == 1; - let has_size = (header >> 1) & 1 == 1; - - if !has_size { - let remaining = self.buf.remaining(); - let obu = self.buf.copy_to_bytes(remaining); - return Some(Ok(obu)); - } - - // LEB128 size field starts after header byte and optional extension byte - let mut size: usize = 0; - let mut offset = if has_extension { 2 } else { 1 }; - let mut shift = 0; - - loop { - if offset >= data.len() { - return None; - } - - let byte = data[offset]; - offset += 1; - - size |= ((byte & 0x7F) as usize) << shift; - shift += 7; - - if byte & 0x80 == 0 { - break; - } - - if shift >= 56 { - return Some(Err(anyhow::anyhow!("OBU size too large"))); - } - } - - let total_size = offset + size; - - if total_size > self.buf.remaining() { - return None; - } - - let obu = self.buf.copy_to_bytes(total_size); - Some(Ok(obu)) } + obus.flush().ok().flatten().filter(|obu| is_sequence_header(obu)) } diff --git a/rs/moq-mux/src/codec/av1/mod.rs b/rs/moq-mux/src/codec/av1/mod.rs index d67c5b6a5..d5fbf08ee 100644 --- a/rs/moq-mux/src/codec/av1/mod.rs +++ b/rs/moq-mux/src/codec/av1/mod.rs @@ -5,11 +5,44 @@ //! raw AV1 bitstreams (OBU-framed) to a moq broadcast. mod import; +mod split; pub use import::*; +pub use split::*; use hang::catalog::AV1; +/// AV1 parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("OBU is too short")] + ObuTooShort, + + #[error("OBU size too large")] + ObuSizeTooLarge, + + #[error("not initialized")] + NotInitialized, + + #[error("expected sequence header before any frames")] + MissingSequenceHeader, + + #[error("missing timestamp")] + MissingTimestamp, + + #[error("OBU header parse: {0}")] + ObuHeaderParse(std::sync::Arc), +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Error::ObuHeaderParse(std::sync::Arc::new(err)) + } +} + +pub type Result = std::result::Result; + /// Build a catalog [`VideoConfig`](hang::catalog::VideoConfig) for the `av01` /// shape from an AV1CodecConfigurationRecord (av1C). /// @@ -18,10 +51,12 @@ use hang::catalog::AV1; /// raw OBU temporal units, so the record passes straight through as the catalog /// `description`. Resolution and color live in the inline sequence header, not /// the av1C, so `coded_width`/`coded_height` are left unset here. -pub(crate) fn config_from_av1c(av1c: &[u8]) -> anyhow::Result { +pub(crate) fn config_from_av1c(av1c: &[u8]) -> Result { // av1C: byte 0 = marker(1)|version(7) = 0x81, byte 1 = seq_profile(3)|seq_level_idx_0(5), // byte 2 = seq_tier_0|high_bitdepth|twelve_bit|monochrome|subsampling_x|subsampling_y|sample_position(2). - anyhow::ensure!(av1c.len() >= 4 && av1c[0] == 0x81, "invalid av1C record"); + if av1c.len() < 4 || av1c[0] != 0x81 { + return Err(Error::ObuTooShort); + } let high_bitdepth = ((av1c[2] >> 6) & 0x01) == 1; let twelve_bit = ((av1c[2] >> 5) & 0x01) == 1; @@ -59,6 +94,32 @@ pub(crate) fn av1_from_av1c(av1c: &mp4_atom::Av1c) -> AV1 { } } +/// Build an `mp4_atom::Av1c` (AV1CodecConfigurationRecord) from the hang +/// catalog's AV1 codec struct, the inverse of [`av1_from_av1c`]. +/// +/// `config_obus` is left empty: moq-video publishes AV1 with the sequence +/// header inline in the bitstream (the `.av01` in-band case, analogous to +/// `hev1`/`avc3`), so the decoder reads it from the keyframe rather than the +/// out-of-band config record. The catalog's color fields (color primaries, +/// transfer characteristics, matrix coefficients, full range) have no slot in +/// av1C; they live in the sequence header OBU instead. +pub(crate) fn av1c_from_av1(av1: &AV1) -> mp4_atom::Av1c { + let (twelve_bit, high_bitdepth) = bitdepth_flags(av1.bitdepth); + mp4_atom::Av1c { + seq_profile: av1.profile, + seq_level_idx_0: av1.level, + seq_tier_0: av1.tier == 'H', + high_bitdepth, + twelve_bit, + monochrome: av1.mono_chrome, + chroma_subsampling_x: av1.chroma_subsampling_x, + chroma_subsampling_y: av1.chroma_subsampling_y, + chroma_sample_position: av1.chroma_sample_position, + initial_presentation_delay: None, + config_obus: Vec::new(), + } +} + /// Bit depth from the (twelve_bit, high_bitdepth) av1C flag pair /// (ISO/IEC 14496-15 + av1-isobmff §2.3.3). /// @@ -67,9 +128,17 @@ pub(crate) fn bitdepth(twelve_bit: bool, high_bitdepth: bool) -> u8 { 8 + 2 * u8::from(high_bitdepth) + 2 * u8::from(twelve_bit) } +/// The (twelve_bit, high_bitdepth) av1C flag pair for a given bit depth, the +/// inverse of [`bitdepth`]. 8-bit -> (false, false), 10-bit -> (false, true), +/// 12-bit -> (true, true). +pub(crate) fn bitdepth_flags(bitdepth: u8) -> (bool, bool) { + (bitdepth >= 12, bitdepth >= 10) +} + #[cfg(test)] mod tests { - use super::bitdepth; + use super::{av1_from_av1c, av1c_from_av1, bitdepth, bitdepth_flags}; + use hang::catalog::AV1; #[test] fn maps_bitdepth_flags() { @@ -80,4 +149,50 @@ mod tests { // per the spec, but the additive formula still gives a defined answer. assert_eq!(bitdepth(true, false), 10); } + + #[test] + fn bitdepth_flags_round_trip() { + for (depth, flags) in [(8, (false, false)), (10, (false, true)), (12, (true, true))] { + assert_eq!(bitdepth_flags(depth), flags); + let (twelve_bit, high_bitdepth) = flags; + assert_eq!(bitdepth(twelve_bit, high_bitdepth), depth); + } + } + + #[test] + fn av1c_round_trips_catalog_fields() { + let av1 = AV1 { + profile: 0, + level: 8, + tier: 'H', + bitdepth: 10, + mono_chrome: false, + chroma_subsampling_x: true, + chroma_subsampling_y: true, + chroma_sample_position: 2, + // Color fields have no av1C slot; they live in the sequence header. + ..Default::default() + }; + + let av1c = av1c_from_av1(&av1); + assert_eq!(av1c.seq_profile, 0); + assert_eq!(av1c.seq_level_idx_0, 8); + assert!(av1c.seq_tier_0); + assert!(av1c.high_bitdepth); + assert!(!av1c.twelve_bit); + assert!(av1c.chroma_subsampling_x); + assert!(av1c.chroma_subsampling_y); + assert_eq!(av1c.chroma_sample_position, 2); + assert!(av1c.config_obus.is_empty()); + + // The av1C-backed fields survive a round trip back to the catalog. + let back = av1_from_av1c(&av1c); + assert_eq!(back.profile, av1.profile); + assert_eq!(back.level, av1.level); + assert_eq!(back.bitdepth, av1.bitdepth); + assert_eq!(back.mono_chrome, av1.mono_chrome); + assert_eq!(back.chroma_subsampling_x, av1.chroma_subsampling_x); + assert_eq!(back.chroma_subsampling_y, av1.chroma_subsampling_y); + assert_eq!(back.chroma_sample_position, av1.chroma_sample_position); + } } diff --git a/rs/moq-mux/src/codec/av1/split.rs b/rs/moq-mux/src/codec/av1/split.rs new file mode 100644 index 000000000..64d4ab738 --- /dev/null +++ b/rs/moq-mux/src/codec/av1/split.rs @@ -0,0 +1,343 @@ +//! AV1 OBU stream splitter. +//! +//! The AV1 analogue of [`crate::codec::h264::Split`]: turns a raw OBU byte +//! stream into [`crate::container::Frame`]s. It finds temporal-unit boundaries +//! and flags keyframes (a sequence header or a `KEY_FRAME`), and stamps +//! wall-clock timestamps when the caller has none (stdin). It owns no track, +//! catalog, or codec config. AV1 carries the sequence header inline ahead of +//! keyframes, so unlike H.264/H.265 there is nothing to cache or re-insert; the +//! importer parses the config out of the frames it emits. + +use bytes::{Buf, Bytes, BytesMut}; +use scuffle_av1::{ObuHeader, ObuType}; + +use super::Error; +use crate::Result; + +/// AV1 OBU stream splitter: bytes in, [`Frame`](crate::container::Frame)s out. +/// +/// Feed bytes via [`decode`](Self::decode) (unknown frame boundaries, e.g. +/// stdin); call [`flush`](Self::flush) to emit the final in-flight temporal unit. +pub struct Split { + current: Au, + zero: Option, + pending: Vec, + // Bytes carried across calls: a partial OBU at the tail of one `decode` waits + // here for the rest to arrive on the next call. + tail: BytesMut, +} + +#[derive(Default)] +struct Au { + chunks: BytesMut, + contains_keyframe: bool, + contains_frame: bool, +} + +impl Default for Split { + fn default() -> Self { + Self::new() + } +} + +impl Split { + /// A fresh splitter. + pub fn new() -> Self { + Self { + current: Au::default(), + zero: None, + pending: Vec::new(), + tail: BytesMut::new(), + } + } + + /// Decode a buffer where frame boundaries are unknown, returning the temporal + /// units it can complete. The final temporal unit stays buffered until the + /// next call (or [`flush`](Self::flush)). + pub fn decode( + &mut self, + data: &[u8], + pts: impl Into>, + ) -> Result> { + let hint = pts.into(); + self.tail.extend_from_slice(data); + // Pull complete OBUs out of `tail`, leaving any trailing partial OBU + // buffered for the next call. Collect first so `tail` isn't borrowed while + // `decode_obu` mutates `self`. + let obus = { + let it = ObuIterator::new(&mut self.tail); + it.collect::>>()? + }; + for obu in obus { + // Resolve a timestamp per OBU so a wall-clock stream doesn't reuse one. + let pts = self.pts(hint)?; + self.decode_obu(obu, Some(pts))?; + } + Ok(std::mem::take(&mut self.pending)) + } + + /// Emit the in-flight temporal unit, if any. Call after the last + /// [`decode`](Self::decode) when a caller handed over a complete temporal unit + /// (or at end of stream) so the final unit isn't left buffered. + pub fn flush( + &mut self, + pts: impl Into>, + ) -> Result> { + let pts = self.pts(pts.into())?; + self.maybe_start_frame(Some(pts))?; + Ok(std::mem::take(&mut self.pending)) + } + + fn decode_obu(&mut self, obu_data: Bytes, pts: Option) -> Result<()> { + if obu_data.is_empty() { + return Err(Error::ObuTooShort.into()); + } + + // Parse the OBU header to learn the type; the payload offset is whatever + // the parser consumed (header + optional extension + LEB128 size). + let mut reader = &obu_data[..]; + let header = ObuHeader::parse(&mut reader)?; + let payload_offset = obu_data.len() - reader.len(); + + match header.obu_type { + // A sequence header anchors a keyframe; the importer parses the config. + ObuType::SequenceHeader => { + self.current.contains_keyframe = true; + } + ObuType::TemporalDelimiter => { + self.maybe_start_frame(pts)?; + } + ObuType::FrameHeader | ObuType::Frame => { + let is_keyframe = obu_data.get(payload_offset).is_some_and(|first_byte| { + let show_existing_frame = (first_byte >> 7) & 1; + if show_existing_frame == 1 { + self.current.contains_keyframe + } else { + let frame_type = (first_byte >> 5) & 0b11; + frame_type == 0 // KEY_FRAME + } + }); + + if is_keyframe { + self.current.contains_keyframe = true; + } + self.current.contains_frame = true; + } + ObuType::Metadata => { + self.maybe_start_frame(pts)?; + } + ObuType::TileGroup | ObuType::TileList => { + self.current.contains_frame = true; + } + _ => {} + } + + tracing::trace!(?header.obu_type, "parsed OBU"); + + self.current.chunks.extend_from_slice(&obu_data); + Ok(()) + } + + fn maybe_start_frame(&mut self, pts: Option) -> Result<()> { + if !self.current.contains_frame { + return Ok(()); + } + + let pts = pts.ok_or(Error::MissingTimestamp)?; + let keyframe = self.current.contains_keyframe; + let payload = std::mem::take(&mut self.current.chunks).freeze(); + self.current.contains_keyframe = false; + self.current.contains_frame = false; + + self.pending.push(crate::container::Frame { + timestamp: pts, + payload, + keyframe, + duration: None, + }); + Ok(()) + } + + /// Drop any in-flight temporal unit. + /// + /// Pre-reset OBUs would otherwise leak into a later frame with the wrong + /// timestamp. + pub fn reset(&mut self) { + self.current = Au::default(); + // Drop any buffered partial OBU too, so pre-reset bytes can't leak into the next unit. + self.tail.clear(); + } + + fn pts(&mut self, hint: Option) -> Result { + if let Some(pts) = hint { + return Ok(pts); + } + let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); + Ok(crate::container::Timestamp::from_micros( + zero.elapsed().as_micros() as u64 + )?) + } +} + +/// Iterator over AV1 Open Bitstream Units (OBUs). +pub(super) struct ObuIterator<'a, T: Buf + AsRef<[u8]> + 'a> { + buf: &'a mut T, +} + +impl<'a, T: Buf + AsRef<[u8]> + 'a> ObuIterator<'a, T> { + pub fn new(buf: &'a mut T) -> Self { + Self { buf } + } + + pub fn flush(self) -> Result> { + let remaining = self.buf.remaining(); + if remaining == 0 { + return Ok(None); + } + Ok(Some(self.buf.copy_to_bytes(remaining))) + } +} + +impl<'a, T: Buf + AsRef<[u8]> + 'a> Iterator for ObuIterator<'a, T> { + type Item = Result; + + fn next(&mut self) -> Option { + if self.buf.remaining() == 0 { + return None; + } + + let data = self.buf.as_ref(); + if data.is_empty() { + return None; + } + + // OBU header: forbidden(1) | type(4) | extension_flag(1) | has_size(1) | reserved(1) + let header = data[0]; + let has_extension = (header >> 2) & 1 == 1; + let has_size = (header >> 1) & 1 == 1; + + if !has_size { + let remaining = self.buf.remaining(); + return Some(Ok(self.buf.copy_to_bytes(remaining))); + } + + // LEB128 size field follows the header byte and optional extension byte. + let mut size: usize = 0; + let mut offset = if has_extension { 2 } else { 1 }; + let mut shift = 0; + + loop { + if offset >= data.len() { + return None; + } + + let byte = data[offset]; + offset += 1; + + size |= ((byte & 0x7F) as usize) << shift; + shift += 7; + + if byte & 0x80 == 0 { + break; + } + if shift >= 56 { + return Some(Err(Error::ObuSizeTooLarge.into())); + } + } + + let total_size = offset + size; + if total_size > self.buf.remaining() { + return None; + } + + Some(Ok(self.buf.copy_to_bytes(total_size))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // OBU header byte: forbidden(0) | type(4) | extension_flag(0) | has_size(1) | reserved(0). + fn obu(obu_type: u8, payload: &[u8]) -> Vec { + let mut o = vec![(obu_type << 3) | 0b010, payload.len() as u8]; + o.extend_from_slice(payload); + o + } + + fn td() -> Vec { + obu(2, &[]) // OBU_TEMPORAL_DELIMITER + } + fn seq_header() -> Vec { + obu(1, &[0xaa, 0xbb]) // OBU_SEQUENCE_HEADER (payload not parsed by the splitter) + } + fn key_frame() -> Vec { + obu(6, &[0x00, 0x11]) // OBU_FRAME, first byte: show_existing=0, frame_type=0 (KEY_FRAME) + } + fn inter_frame() -> Vec { + obu(6, &[0x20, 0x11]) // OBU_FRAME, first byte: frame_type=1 (INTER_FRAME) + } + + fn cat(parts: &[Vec]) -> BytesMut { + let mut buf = BytesMut::new(); + for p in parts { + buf.extend_from_slice(p); + } + buf + } + + fn ts() -> crate::container::Timestamp { + crate::container::Timestamp::from_micros(0).unwrap() + } + + /// Decode one complete temporal unit handed over as a single buffer: `decode` + /// buffers it, `flush` emits it. + fn decode_one( + split: &mut Split, + buf: &mut BytesMut, + pts: crate::container::Timestamp, + ) -> Vec { + let mut frames = split.decode(buf, Some(pts)).unwrap(); + frames.extend(split.flush(Some(pts)).unwrap()); + frames + } + + /// A temporal unit with a sequence header + KEY_FRAME emits one keyframe. + #[tokio::test(start_paused = true)] + async fn decode_keyframe() { + let mut split = Split::new(); + let frames = decode_one(&mut split, &mut cat(&[td(), seq_header(), key_frame()]), ts()); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe); + } + + /// A frame with no sequence header and INTER frame_type is not a keyframe. + #[tokio::test(start_paused = true)] + async fn decode_delta_is_not_keyframe() { + let mut split = Split::new(); + let frames = decode_one(&mut split, &mut cat(&[td(), inter_frame()]), ts()); + assert_eq!(frames.len(), 1); + assert!(!frames[0].keyframe); + } + + /// In streaming mode the next temporal delimiter closes the previous unit, so + /// the trailing one stays buffered until `flush`. + #[tokio::test(start_paused = true)] + async fn decode_emits_on_next_boundary() { + let mut split = Split::new(); + let frames = split + .decode( + &cat(&[td(), seq_header(), key_frame(), td(), inter_frame()]), + Some(ts()), + ) + .unwrap(); + // Only the keyframe is complete; the inter frame waits for the next TD. + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe); + + // Flushing closes the buffered inter frame. + let tail = split.flush(Some(ts())).unwrap(); + assert_eq!(tail.len(), 1); + assert!(!tail[0].keyframe); + } +} diff --git a/rs/moq-mux/src/codec/eac3.rs b/rs/moq-mux/src/codec/eac3.rs index 920766fcd..621e0bb4c 100644 --- a/rs/moq-mux/src/codec/eac3.rs +++ b/rs/moq-mux/src/codec/eac3.rs @@ -26,41 +26,51 @@ const BLOCKS: [u64; 4] = [1, 2, 3, 6]; const CHANNELS: [u32; 8] = [2, 1, 2, 3, 3, 4, 4, 5]; /// Parse an E-AC-3 sync frame header from the start of `data` (needs >= 6 bytes). -pub(crate) fn parse_header(data: &[u8]) -> anyhow::Result { - anyhow::ensure!(data.len() >= 6, "E-AC-3 header needs 6 bytes"); - anyhow::ensure!(data[0] == 0x0B && data[1] == 0x77, "missing E-AC-3 sync word"); +pub(crate) fn parse_header(data: &[u8]) -> legacy::Result { + if data.len() < 6 { + return Err(legacy::Error::Eac3HeaderTooShort); + } + if !(data[0] == 0x0B && data[1] == 0x77) { + return Err(legacy::Error::Eac3MissingSyncWord); + } // bsid 11..=16 is E-AC-3; plain AC-3 (bsid <= 8) is routed by stream_type and // never reaches this parser. let bsid = data[5] >> 3; - anyhow::ensure!((11..=16).contains(&bsid), "not an E-AC-3 bitstream (bsid {bsid})"); + if !(11..=16).contains(&bsid) { + return Err(legacy::Error::Eac3NotEac3Bsid(bsid)); + } // A dependent substream (strmtyp 1) extends a prior program to 7.1+, and a // nonzero substreamid carries additional programs in the same PID. Either // would make this track's channel count a lie, so they are rejected rather // than mis-described. let strmtyp = data[2] >> 6; - anyhow::ensure!(strmtyp != 3, "reserved E-AC-3 stream type"); - anyhow::ensure!( - strmtyp != 1, - "E-AC-3 dependent substream (7.1+ layout) is not supported; only a single independent substream" - ); + if strmtyp == 3 { + return Err(legacy::Error::Eac3ReservedStreamType); + } + if strmtyp == 1 { + return Err(legacy::Error::Eac3DependentSubstream); + } let substreamid = (data[2] >> 3) & 0x07; - anyhow::ensure!( - substreamid == 0, - "E-AC-3 additional substream {substreamid} is not supported; only a single independent substream" - ); + if substreamid != 0 { + return Err(legacy::Error::Eac3AdditionalSubstream(substreamid)); + } let frmsiz = (((data[2] & 0x07) as usize) << 8) | data[3] as usize; let len = (frmsiz + 1) * 2; // frmsiz is a raw field; corrupt input can declare a "frame" shorter than the // header just parsed, which would surface later as a confusing sync error. - anyhow::ensure!(len >= 6, "E-AC-3 frame length {len} shorter than its header"); + if len < 6 { + return Err(legacy::Error::Eac3FrameShorterThanHeader(len)); + } let fscod = data[4] >> 6; let (sample_rate, blocks) = if fscod == 0b11 { let fscod2 = (data[4] >> 4) & 0x03; - anyhow::ensure!(fscod2 != 3, "reserved E-AC-3 sample-rate code"); + if fscod2 == 3 { + return Err(legacy::Error::Eac3ReservedSampleRate); + } // Reduced rates always run 6 blocks. (SAMPLE_RATE_REDUCED[fscod2 as usize], 6) } else { diff --git a/rs/moq-mux/src/codec/h264/export.rs b/rs/moq-mux/src/codec/h264/export.rs new file mode 100644 index 000000000..08e7bc48b --- /dev/null +++ b/rs/moq-mux/src/codec/h264/export.rs @@ -0,0 +1,327 @@ +//! H.264 single-rendition Annex-B exporter. +//! +//! Subscribes to one H.264 rendition from a catalog-narrowed stream and emits +//! a raw Annex-B elementary stream. Suitable for piping into `ffmpeg`, decoder +//! fuzzers, or recording one codec to disk. There is no container framing +//! (timestamps are dropped). +//! +//! Two source shapes are accepted: +//! - **avc3** (catalog `description` empty): payload is already Annex-B with +//! SPS/PPS inline. Pass through unchanged. +//! - **avc1** (catalog `description` is the avcC): length-prefixed NALUs. +//! Length prefixes are replaced with `00 00 00 01` start codes; SPS/PPS +//! extracted from the avcC are injected ahead of every keyframe. + +use std::task::Poll; +use std::time::Duration; + +use bytes::Bytes; +use hang::Catalog; +use hang::catalog::{VideoCodecKind, VideoConfig}; + +use crate::catalog::Stream; +use crate::codec::annexb; +use crate::container::ExportSource; + +/// Single-rendition H.264 Annex-B exporter. +pub struct Export { + broadcast: moq_net::BroadcastConsumer, + catalog: Option, + latency: Duration, + track: Option, +} + +struct H264Track { + name: String, + /// Snapshot of the catalog config we built `source` from. Cached so that + /// a catalog update which keeps the same rendition name but changes the + /// codec config (e.g. a new avcC) triggers a full rebuild instead of + /// silently reusing a stale `convert`. + config: VideoConfig, + source: ExportSource, + /// `Some` for an avc1 source: SPS/PPS prefix prebuilt from the avcC, and + /// the avcC length-prefix size. `None` for an avc3 source: Annex-B passes + /// through without conversion. + convert: Option, +} + +struct Avc1Convert { + length_size: usize, + keyframe_prefix: Bytes, +} + +impl Export { + /// Subscribe to `broadcast` and emit an Annex-B H.264 byte stream. + /// + /// `catalog` is expected to be narrowed to a single H.264 rendition (e.g. + /// `consumer.filter()` with `codec = H264` then `.target()` for ABR + /// selection). Renditions of other codecs are ignored; if multiple H.264 + /// renditions appear in a snapshot, the first by BTreeMap order wins and + /// a warning is logged. + pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { + Self { + broadcast, + catalog: Some(catalog), + latency: Duration::ZERO, + track: None, + } + } + + /// Set the maximum buffering latency for the per-track source. + pub fn with_latency(mut self, latency: Duration) -> Self { + self.latency = latency; + self + } + + pub async fn next(&mut self) -> crate::Result> { + kio::wait(|waiter| self.poll_next(waiter)).await + } + + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + while let Some(catalog) = self.catalog.as_mut() { + match catalog.poll_next(waiter)? { + Poll::Ready(Some(snapshot)) => self.update_catalog(&snapshot.media())?, + Poll::Ready(None) => { + self.catalog = None; + break; + } + Poll::Pending => break, + } + } + + loop { + let Some(track) = self.track.as_mut() else { + if self.catalog.is_none() { + return Poll::Ready(Ok(None)); + } + return Poll::Pending; + }; + + match track.source.poll_read(waiter) { + Poll::Ready(Ok(Some(frame))) => { + let bytes = match &track.convert { + None => frame.payload, + Some(convert) => { + let prefix = frame.keyframe.then(|| convert.keyframe_prefix.as_ref()); + annexb::from_length_prefixed(&frame.payload, convert.length_size, prefix)? + } + }; + if bytes.is_empty() { + continue; + } + return Poll::Ready(Ok(Some(bytes))); + } + Poll::Ready(Ok(None)) => { + self.track = None; + continue; + } + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, + } + } + } + + fn update_catalog(&mut self, catalog: &Catalog) -> crate::Result<()> { + let picked = catalog + .video + .renditions + .iter() + .filter(|(_, c)| c.codec.kind() == VideoCodecKind::H264) + .collect::>(); + + if picked.len() > 1 { + tracing::warn!( + count = picked.len(), + "multiple H.264 renditions in catalog snapshot; using the first by name. \ + Narrow with catalog::Target to pick one explicitly." + ); + } + + let Some((name, config)) = picked.into_iter().next() else { + self.track = None; + return Ok(()); + }; + + if self + .track + .as_ref() + .is_some_and(|t| t.name == *name && t.config == *config) + { + return Ok(()); + } + + let source = ExportSource::for_video_raw(&self.broadcast, name, config, self.latency)?; + let convert = match config.description.as_ref().filter(|d| !d.is_empty()) { + None => None, + Some(avcc) => { + let params = super::Avcc::parse(avcc)?; + if params.sps.is_empty() || params.pps.is_empty() { + return Err(super::Error::MissingParamSets { + name: name.clone(), + sps: params.sps.len(), + pps: params.pps.len(), + } + .into()); + } + let prefix = annexb::build_prefix(params.sps.iter().chain(params.pps.iter())); + Some(Avc1Convert { + length_size: params.length_size, + keyframe_prefix: prefix, + }) + } + }; + + self.track = Some(H264Track { + name: name.clone(), + config: config.clone(), + source, + convert, + }); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::task::Poll; + + use bytes::Bytes; + use hang::catalog::{H264, Video, VideoConfig}; + + use super::*; + use crate::catalog::Stream; + use crate::catalog::hang::Catalog; + + /// One-shot Stream that yields a single catalog snapshot then closes. + struct Once(Option); + + impl Stream for Once { + type Ext = (); + + fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { + Poll::Ready(Ok(self.0.take())) + } + } + + /// Build an avc1-shaped catalog snapshot with the supplied avcC bytes. + fn avc1_catalog(name: &str, avcc: Bytes) -> Catalog { + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1f, + inline: false, + }); + config.coded_width = Some(320); + config.coded_height = Some(240); + config.description = Some(avcc); + config.container = hang::catalog::Container::Legacy; + + let mut renditions = BTreeMap::new(); + renditions.insert(name.to_string(), config); + + Catalog { + video: Video { + renditions, + display: None, + rotation: None, + flip: None, + }, + ..Default::default() + } + } + + /// Build a minimal avcC carrying one SPS + one PPS. + fn build_avcc(sps: &[u8], pps: &[u8]) -> Bytes { + super::super::build_avcc(&[Bytes::copy_from_slice(sps)], &[Bytes::copy_from_slice(pps)]).unwrap() + } + + /// Write a length-prefixed (4-byte) NAL frame onto a moq-net group via + /// the Legacy wire codec. + fn write_length_prefixed(group: &mut moq_net::GroupProducer, timestamp_us: u64, nals: &[&[u8]]) { + let mut payload = bytes::BytesMut::new(); + for nal in nals { + payload.extend_from_slice(&(nal.len() as u32).to_be_bytes()); + payload.extend_from_slice(nal); + } + let frame = crate::container::Frame { + timestamp: crate::container::Timestamp::from_micros(timestamp_us).unwrap(), + payload: payload.freeze(), + keyframe: false, // Legacy wire format drops this; Consumer reconstructs. + duration: None, + }; + ::write( + &crate::catalog::hang::Container::Legacy, + group, + &[frame], + ) + .unwrap(); + } + + /// Regression: when source is avc1 (length-prefixed + out-of-band avcC), + /// the exporter must inject SPS+PPS before every keyframe and convert + /// length prefixes to start codes for every frame. + #[tokio::test(start_paused = true)] + async fn avc1_export_injects_sps_pps_on_keyframes() { + let sps: &[u8] = &[ + 0x67, 0x42, 0xc0, 0x1f, 0xda, 0x01, 0x40, 0x16, 0xe9, 0xb8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x07, 0xd0, 0x00, + 0x01, 0xd4, 0xc0, 0x80, + ]; + let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + let p_slice: &[u8] = &[0x61, 0xe0, 0x12, 0x34]; + + let avcc = build_avcc(sps, pps); + let catalog = avc1_catalog("video.m4s", avcc); + + // Producer side: publish the broadcast with one length-prefixed video track. + let mut broadcast = moq_net::Broadcast::new().produce(); + let mut track = broadcast.create_track(moq_net::Track::new("video.m4s")).unwrap(); + + // Group 0 (keyframe-starting group): one IDR frame. + let mut g0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); + write_length_prefixed(&mut g0, 0, &[idr]); + g0.finish().unwrap(); + + // Group 1 (next group): one P-slice. Consumer marks the first frame + // of every group as keyframe by protocol invariant, so the exporter + // MUST treat both group-starts as keyframes and inject SPS+PPS twice. + let mut g1 = track.create_group(moq_net::Group { sequence: 1 }).unwrap(); + write_length_prefixed(&mut g1, 33_000, &[p_slice]); + g1.finish().unwrap(); + track.finish().unwrap(); + + // Consumer side: run the exporter. + let consumer = broadcast.consume(); + let mut export = Export::new(consumer, Once(Some(catalog))); + + let frame0 = export.next().await.unwrap().expect("first frame"); + let frame1 = export.next().await.unwrap().expect("second frame"); + assert!(export.next().await.unwrap().is_none(), "track ended"); + + // Build the expected SPS+PPS prefix and assert it's prepended to both + // frames (group boundaries become keyframes). + let prefix = + crate::codec::annexb::build_prefix([Bytes::copy_from_slice(sps), Bytes::copy_from_slice(pps)].iter()); + + assert!( + frame0.starts_with(&prefix), + "frame 0 (group 0 start) must begin with SPS+PPS prefix" + ); + assert_eq!( + &frame0[prefix.len()..], + &[0, 0, 0, 1, 0x65, 0x88, 0x84, 0x21], + "frame 0 IDR must follow the prefix in Annex-B form" + ); + assert!( + frame1.starts_with(&prefix), + "frame 1 (group 1 start) is the first frame of its group and is treated as a keyframe by Consumer protocol; must begin with SPS+PPS prefix" + ); + assert_eq!( + &frame1[prefix.len()..], + &[0, 0, 0, 1, 0x61, 0xe0, 0x12, 0x34], + "frame 1 P-slice must follow the prefix in Annex-B form" + ); + } +} diff --git a/rs/moq-mux/src/codec/h264/import.rs b/rs/moq-mux/src/codec/h264/import.rs index 42a39c167..a57751345 100644 --- a/rs/moq-mux/src/codec/h264/import.rs +++ b/rs/moq-mux/src/codec/h264/import.rs @@ -1,164 +1,81 @@ -//! H.264 importer for both wire shapes. +//! H.264 importer. //! -//! [`Import`] accepts either length-prefixed NALU input with an -//! out-of-band [`AVCDecoderConfigurationRecord`](super::Avcc) (the "avc1" -//! shape) or Annex-B input with inline SPS/PPS (the "avc3" shape). The shape -//! is detected at [`initialize`](Import::initialize) time by looking for a -//! leading start code; callers that already know it can also force the -//! mode via [`with_mode`](Import::with_mode). - -use anyhow::Context; -use bytes::{Buf, Bytes, BytesMut}; -use tokio::io::{AsyncRead, AsyncReadExt}; - -use super::Sps; +//! [`Import`] publishes already-split H.264 frames on a single moq track and +//! resolves the catalog rendition. It is a pure frame publisher: byte parsing +//! and framing live in [`Split`](super::Split), and whoever drives the import owns the split. +//! Frames arrive via [`decode`](Import::decode). +//! +//! The codec config comes from exactly one of two places: an avcC handed to +//! [`initialize`](Import::initialize) (the "avc1" shape), or the SPS the splitter +//! packages into the first keyframe (the "avc3" shape, scanned out of the frame +//! here). A keyframe that can't be configured from either is an error; +//! non-keyframes before the first config are tolerated (mid-stream joins). + +use bytes::Bytes; + +use super::{Error, NAL_TYPE_SPS, Sps}; +use crate::Result; use crate::catalog::hang::CatalogExt; -use crate::codec::annexb::{NalIterator, START_CODE}; +use crate::codec::annexb::NalIterator; +use crate::container::Frame; use crate::container::jitter::Jitter; -/// The wire shape an [`Import`] is processing. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Mode { - /// Length-prefixed NALU with out-of-band AVCDecoderConfigurationRecord - /// (catalog `H264 { inline: false }`, `description = avcC`). - Avc1, - /// Annex-B (start-code prefixed) with inline SPS/PPS - /// (catalog `H264 { inline: true }`, no description). - Avc3, -} - -/// H.264 importer. Handles both avc1 (length-prefixed) and avc3 (Annex-B) -/// input streams; the shape is detected from the first bytes the caller -/// supplies, or forced explicitly via [`with_mode`](Self::with_mode). +/// H.264 importer: a pure frame publisher that resolves the catalog rendition. +/// +/// Build it with [`new`](Self::new), passing the track producer and the +/// [`catalog::Producer`](crate::catalog::Producer) it publishes its rendition into. +/// Feed it frames a [`Split`](super::Split) produced via [`decode`](Self::decode). +/// The catalog rendition fills in lazily once the codec config is known (avcC via +/// [`initialize`](Self::initialize) for avc1, the first SPS for avc3). pub struct Import { - tracks: crate::track_provider::TrackProvider, - catalog: crate::catalog::Producer, - track: Option>, + /// True for the avc1 shape: the codec config is out-of-band (avcC), so + /// keyframes are not scanned for an inline SPS. + avc1: bool, + track: crate::container::Producer, + rendition: crate::catalog::VideoTrack, config: Option, - state: State, - zero: Option, + last_sps: Option, jitter: Jitter, } -enum State { - /// No bytes seen yet; mode pinned ahead of time or unknown. - Pending { mode_hint: Option }, - /// avc1 wire shape: length-prefixed NALU, codec config out-of-band. - Avc1 { length_size: usize }, - /// avc3 wire shape: Annex-B NALU, inline SPS/PPS. - Avc3 { - current: Avc3Frame, - /// Retained SPS NALs from the latest keyframe that carried them, re-injected - /// on bare keyframes. Replaced (not accumulated) when a keyframe presents a - /// different set, so a mid-stream reinit drops the superseded ones. - sps: Vec, - /// Retained PPS NALs. A keyframe may carry several (slices reference them by - /// id); all are kept and re-injected, but a new GOP's set supersedes them. - pps: Vec, - }, -} - -#[derive(Default)] -struct Avc3Frame { - chunks: BytesMut, - contains_idr: bool, - contains_slice: bool, - /// SPS NALs already inline in this access unit, so re-injection skips them. - sps_seen: Vec, - /// PPS NALs already inline in this access unit. - pps_seen: Vec, -} - impl Import { - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { + /// Publish on an existing track producer, registering the rendition in `catalog`. + pub fn new(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { + let rendition = catalog.video_track(track.name()); Self { - tracks: crate::track_provider::TrackProvider::unique(broadcast, ".avc3"), - catalog, - track: None, + avc1: false, + track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), + rendition, config: None, - state: State::Pending { mode_hint: None }, - zero: None, + last_sps: None, jitter: Jitter::new(), } } - pub fn new_with_track(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { - Self { - tracks: crate::track_provider::TrackProvider::fixed(track), - catalog, - track: None, - config: None, - state: State::Pending { mode_hint: None }, - zero: None, - jitter: Jitter::new(), - } - } - - /// Pin the wire shape ahead of time; skips the leading-bytes auto-detect - /// inside [`initialize`](Self::initialize). Eagerly creates the broadcast - /// track for avc3 sources so the caller can observe subscriber state - /// (`used()` / `unused()`) before any frames arrive. - pub fn with_mode(mut self, mode: Mode) -> anyhow::Result { - match mode { - Mode::Avc1 => { - self.tracks.set_suffix(".avc1"); - self.state = State::Pending { - mode_hint: Some(Mode::Avc1), - }; - } - Mode::Avc3 => { - self.tracks.set_suffix(".avc3"); - let track = self.tracks.create()?; - self.track = Some( - crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy) - .with_lenient_start(), - ); - self.state = State::Avc3 { - current: Avc3Frame::default(), - sps: Vec::new(), - pps: Vec::new(), - }; - } - } - Ok(self) - } - - /// Returns a reference to the underlying track producer, e.g. for - /// monitoring subscriber state via `used()` / `unused()`. Available only - /// after the track has been created. i.e. after [`with_mode`](Self::with_mode) - /// for avc3 or after [`initialize`](Self::initialize) for avc1. - pub fn track(&self) -> Option<&moq_net::TrackProducer> { - self.track.as_ref().map(|t| t.track()) - } - - /// Initialize from the codec's leading bytes. + /// Resolve the codec config from the codec's leading bytes. /// - /// - **avc1** (no leading start code): the buffer is parsed as an - /// `AVCDecoderConfigurationRecord` and stored as the catalog `description`. - /// - **avc3** (leading `0x00 0x00 0x01` or `0x00 0x00 0x00 0x01`): the buffer - /// is parsed as Annex-B NALs to seed the cached SPS/PPS. + /// - **avc1** (no leading start code): parsed as an `AVCDecoderConfigurationRecord`, + /// which resolves the config and is stored as the catalog `description`. Required + /// for avc1. + /// - **avc3** (leading start code): parsed as Annex-B; any SPS resolves the config. + /// Optional, since avc3 also self-initializes from the first keyframe. /// - /// The buffer is fully consumed. - pub fn initialize>(&mut self, buf: &mut T) -> anyhow::Result<()> { - let mode = match &self.state { - State::Pending { mode_hint } => mode_hint.unwrap_or_else(|| detect_mode(buf.as_ref())), - State::Avc1 { .. } => Mode::Avc1, - State::Avc3 { .. } => Mode::Avc3, - }; - - match mode { - Mode::Avc1 => self.initialize_avc1(buf), - Mode::Avc3 => self.initialize_avc3(buf), + /// Takes a read-only slice: the dispatcher-owned [`Split`](super::Split) is what + /// consumes the stream (and reads the same avcC for the NALU length size). The + /// shape is detected from the leading bytes. + pub fn initialize(&mut self, buf: &[u8]) -> Result<()> { + if detect_avc1(buf) { + self.initialize_avc1(buf) + } else { + self.initialize_avc3(buf) } } - /// Initialize the avc1 path from an `AVCDecoderConfigurationRecord` buffer. - fn initialize_avc1>(&mut self, buf: &mut T) -> anyhow::Result<()> { - let avcc_bytes = buf.as_ref(); + fn initialize_avc1(&mut self, avcc_bytes: &[u8]) -> Result<()> { + // Only switch to avc1 mode once the avcC actually parses, so a parse failure leaves the + // importer in avc3 mode where inline-SPS keyframes still self-initialize. let avcc = super::Avcc::parse(avcc_bytes)?; - self.state = State::Avc1 { - length_size: avcc.length_size, - }; + self.avc1 = true; let mut config = hang::catalog::VideoConfig::new(hang::catalog::H264 { profile: avcc.profile, @@ -171,241 +88,69 @@ impl Import { config.description = Some(Bytes::copy_from_slice(avcc_bytes)); config.container = hang::catalog::Container::Legacy; - self.tracks.set_suffix(".avc1"); - self.swap_config(config)?; - buf.advance(buf.remaining()); - + self.apply_config(config); Ok(()) } - /// Initialize the avc3 path by parsing Annex-B NALs (SPS/PPS seed the - /// catalog rendition; the track is created eagerly on first SPS). - fn initialize_avc3>(&mut self, buf: &mut T) -> anyhow::Result<()> { - // Eager-create the track + state on first switch into Avc3 mode so - // callers can observe `used()` / `unused()` before any frames arrive. - if !matches!(self.state, State::Avc3 { .. }) { - self.state = State::Avc3 { - current: Avc3Frame::default(), - sps: Vec::new(), - pps: Vec::new(), - }; - if self.track.is_none() { - self.tracks.set_suffix(".avc3"); - let track = self.tracks.create()?; - self.track = Some( - crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy) - .with_lenient_start(), - ); - } - } - - let mut nals = NalIterator::new(buf); + fn initialize_avc3(&mut self, data: &[u8]) -> Result<()> { + // Resolve the config from any SPS in the seed buffer. Scan a clone so the + // caller's buffer is left intact for the splitter to consume. + let mut scan = Bytes::copy_from_slice(data); + let mut nals = NalIterator::new(&mut scan); while let Some(nal) = nals.next().transpose()? { - self.decode_nal(nal, None)?; + if is_sps(&nal) { + self.configure_from_sps(&nal)?; + } } - if let Some(nal) = nals.flush()? { - self.decode_nal(nal, None)?; + if let Some(nal) = nals.flush()? + && is_sps(&nal) + { + self.configure_from_sps(&nal)?; } - Ok(()) } - pub fn is_initialized(&self) -> bool { - self.track.is_some() + /// The MoQ track name this importer publishes on. + pub fn name(&self) -> &str { + self.track.name() } - /// Decode from an asynchronous reader. avc3 only — for avc1, the caller - /// already has framed buffers and uses [`decode_frame`](Self::decode_frame). - pub async fn decode_from(&mut self, reader: &mut T) -> anyhow::Result<()> { - let mut buffer = BytesMut::new(); - while reader.read_buf(&mut buffer).await? > 0 { - self.decode_stream(&mut buffer, None)?; - } - Ok(()) + /// A watch-only handle to this track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + self.track.track().demand() } - /// Decode a buffer where frame boundaries are unknown (avc3 streaming - /// input). The leading start code of the *next* frame is what signals the - /// previous frame is done. - pub fn decode_stream>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - anyhow::ensure!(matches!(self.state, State::Avc3 { .. }), "decode_stream is avc3 only"); - let pts = self.pts(pts)?; - let nals = NalIterator::new(buf); - for nal in nals { - self.decode_nal(nal?, Some(pts))?; - } + /// Finish the track, flushing any buffered data. + pub fn finish(&mut self) -> Result<()> { + self.track.finish()?; Ok(()) } - /// Decode a buffer assumed to hold (the rest of) a single frame. - /// - /// - avc1: the buffer is written as one length-prefixed-NALU frame. - /// - avc3: NALs are parsed; any trailing NAL without a start code is - /// flushed as the last NAL of this frame. - pub fn decode_frame>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - match &self.state { - State::Avc1 { .. } => self.decode_avc1(buf, pts), - State::Avc3 { .. } => self.decode_avc3_frame(buf, pts), - State::Pending { .. } => anyhow::bail!("not initialized; call initialize() or with_mode() first"), - } + /// Close the current group and open the next one at `sequence`. + pub fn seek(&mut self, sequence: u64) -> Result<()> { + self.track.seek(sequence)?; + Ok(()) } /// Record a frame's reorder delay (`PTS - DTS`) so the catalog `jitter` reflects the - /// B-frame reorder depth (the decode buffer a transmuxer/player must hold). The container - /// supplies this since the elementary stream alone carries no decode time. No-op until the - /// track exists. + /// B-frame reorder depth (the decode buffer a transmuxer/player must hold). The + /// container supplies this since the elementary stream alone carries no decode time. pub fn observe_reorder(&mut self, reorder: crate::container::Timestamp) { - let Some(jitter) = self.jitter.observe_reorder(reorder) else { - return; - }; - let Some(track) = self.track.as_ref() else { - return; - }; - if let Some(c) = self.catalog.lock().video.renditions.get_mut(&track.name) { - c.jitter = Some(jitter); + if let Some(jitter) = self.jitter.observe_reorder(reorder) { + self.rendition + .update(|c| c.jitter = moq_net::Time::try_from(jitter).ok()); } } - fn decode_avc1>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - let State::Avc1 { length_size } = self.state else { - unreachable!("checked by decode_frame") - }; - let data = buf.as_ref(); - let pts = self.pts(pts)?; - let keyframe = avc1_is_keyframe(data, length_size); - let track = self - .track - .as_mut() - .context("not initialized; call initialize() first")?; - - track.write(crate::container::Frame { - timestamp: pts, - payload: data.to_vec().into(), - keyframe, - })?; - - if let Some(jitter) = self.jitter.observe(pts) - && let Some(c) = self.catalog.lock().video.renditions.get_mut(&track.name) - { - c.jitter = Some(jitter); - } - - buf.advance(buf.remaining()); - Ok(()) - } - - fn decode_avc3_frame>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - let pts = self.pts(pts)?; - let mut nals = NalIterator::new(buf); - while let Some(nal) = nals.next().transpose()? { - self.decode_nal(nal, Some(pts))?; - } - if let Some(nal) = nals.flush()? { - self.decode_nal(nal, Some(pts))?; - } - self.maybe_start_frame(Some(pts))?; - Ok(()) - } - - fn decode_nal(&mut self, nal: Bytes, pts: Option) -> anyhow::Result<()> { - let header = nal.first().context("NAL unit is too short")?; - let forbidden_zero_bit = (header >> 7) & 1; - anyhow::ensure!(forbidden_zero_bit == 0, "forbidden zero bit is not zero"); - - let nal_unit_type = header & 0b11111; - let nal_type = Avc3NalType::try_from(nal_unit_type).ok(); - - match nal_type { - Some(Avc3NalType::Sps) => { - self.maybe_start_frame(pts)?; - let parsed = Sps::parse(&nal)?; - // A changed config (resolution/profile) means the retained parameter - // sets no longer apply; reconfigured tells us to drop them. - let reconfigured = self.init_from_sps(&parsed)?; - let State::Avc3 { current, sps, pps } = &mut self.state else { - unreachable!("decode_nal is avc3 only") - }; - if reconfigured { - // The retained SPS/PPS are tied to the old config and may already - // have been appended to current.chunks earlier in this AU; reset - // the sets and AU so only the new parameter sets emit. - sps.clear(); - pps.clear(); - current.chunks.clear(); - current.sps_seen.clear(); - current.pps_seen.clear(); - } - // Track only what this AU carries; the retained set is reconciled at - // the keyframe so a new GOP's set replaces (not accumulates onto) it. - crate::codec::annexb::push_distinct(&mut current.sps_seen, &nal); - } - Some(Avc3NalType::Pps) => { - self.maybe_start_frame(pts)?; - let State::Avc3 { current, .. } = &mut self.state else { - unreachable!() - }; - crate::codec::annexb::push_distinct(&mut current.pps_seen, &nal); - } - Some(Avc3NalType::Aud) | Some(Avc3NalType::Sei) => { - self.maybe_start_frame(pts)?; - } - Some(Avc3NalType::IdrSlice) => { - let State::Avc3 { current, sps, pps } = &mut self.state else { - unreachable!() - }; - // Adopt this keyframe's inline set (dropping any the new GOP no longer - // uses), or re-inject the retained set if the keyframe carried none. - crate::codec::annexb::reconcile_keyframe_params(&mut current.chunks, sps, &mut current.sps_seen); - crate::codec::annexb::reconcile_keyframe_params(&mut current.chunks, pps, &mut current.pps_seen); - current.contains_idr = true; - current.contains_slice = true; - } - Some(Avc3NalType::NonIdrSlice) - | Some(Avc3NalType::DataPartitionA) - | Some(Avc3NalType::DataPartitionB) - | Some(Avc3NalType::DataPartitionC) => { - if nal.get(1).context("NAL unit is too short")? & 0x80 != 0 { - self.maybe_start_frame(pts)?; - } - let State::Avc3 { current, .. } = &mut self.state else { - unreachable!() - }; - current.contains_slice = true; - } - _ => {} + /// Resolve the avc3 config from an inline SPS, updating it in place. + /// + /// avc3 carries SPS inline, so a resolution change just updates the config + /// (no new init segment, unlike avc1). + fn configure_from_sps(&mut self, sps_nal: &Bytes) -> Result<()> { + if self.last_sps.as_ref() == Some(sps_nal) { + return Ok(()); } - - tracing::trace!(kind = ?nal_type, "parsed NAL"); - - let State::Avc3 { current, .. } = &mut self.state else { - unreachable!() - }; - current.chunks.extend_from_slice(&START_CODE); - current.chunks.extend_from_slice(&nal); - Ok(()) - } - - /// Publish (or republish) the catalog rendition for this SPS. Returns true if - /// the config changed an existing one (a reconfiguration), so the caller can - /// drop parameter sets tied to the old config. The first SPS is not a - /// reconfiguration. - fn init_from_sps(&mut self, sps: &Sps) -> anyhow::Result { + let sps = Sps::parse(sps_nal)?; let mut config = hang::catalog::VideoConfig::new(hang::catalog::H264 { profile: sps.profile, constraints: sps.constraints, @@ -416,384 +161,223 @@ impl Import { config.coded_height = Some(sps.coded_height); config.container = hang::catalog::Container::Legacy; - match &self.config { - Some(old) if old == &config => Ok(false), - old => { - let reconfigured = old.is_some(); - // The avc3 track was created eagerly in initialize_avc3; just publish - // (or republish) the catalog rendition with the latest config. - let track_name = self.track.as_ref().context("avc3 track not created")?.name.clone(); - // Seed jitter from whatever has accumulated: a dirty start feeds frames before - // this first rendition exists, so those per-frame updates would otherwise be - // lost. Keep the cached `config` jitter-free so a later jitter change is not - // mistaken for a codec reconfiguration. - let mut published = config.clone(); - published.jitter = self.jitter.current(); - self.catalog.lock().video.renditions.insert(track_name, published); - self.config = Some(config); - Ok(reconfigured) - } - } + self.last_sps = Some(sps_nal.clone()); + self.apply_config(config); + Ok(()) } - fn maybe_start_frame(&mut self, pts: Option) -> anyhow::Result<()> { - let State::Avc3 { current, .. } = &mut self.state else { - return Ok(()); - }; - if !current.contains_slice { - return Ok(()); + /// Apply a resolved config, updating the catalog rendition in place. + /// + /// A changed config (new avcC, or a new inline SPS) just re-mirrors the + /// rendition; there are no fixed tracks to reject a reconfiguration. + fn apply_config(&mut self, config: hang::catalog::VideoConfig) { + if self.config.as_ref() == Some(&config) { + return; } - let pts = pts.context("missing timestamp")?; - let payload = std::mem::take(&mut current.chunks).freeze(); - let keyframe = current.contains_idr; - current.contains_idr = false; - current.contains_slice = false; - current.sps_seen.clear(); - current.pps_seen.clear(); - - let track = self.track.as_mut().context("avc3 track not created")?; - track.write(crate::container::Frame { - timestamp: pts, - payload, - keyframe, - })?; - - if let Some(jitter) = self.jitter.observe(pts) - && let Some(c) = self.catalog.lock().video.renditions.get_mut(&track.name) - { - c.jitter = Some(jitter); + tracing::debug!(?config, "starting H.264 track"); + self.rendition.set(config.clone()); + // Seed jitter from whatever has accumulated: a dirty start (or a B-frame + // reorder observed via observe_reorder) can feed updates before this + // rendition exists, so those would otherwise be lost on (re)publish. + if let Some(jitter) = self.jitter.current() { + self.rendition + .update(|c| c.jitter = moq_net::Time::try_from(jitter).ok()); } - Ok(()) + self.config = Some(config); } - /// Replace the current track + catalog rendition with `config`. Used by - /// the avc1 path on every (re)initialization. - fn swap_config(&mut self, config: hang::catalog::VideoConfig) -> anyhow::Result<()> { - if let Some(old) = &self.config - && old == &config - { - return Ok(()); - } + /// Write split frames to the track, resolving the avc3 config from the first + /// keyframe's inline SPS and refining the catalog jitter as it goes. + fn write_frames(&mut self, frames: impl IntoIterator) -> Result<()> { + for frame in frames { + // avc1 config arrives out-of-band via initialize(); avc3 carries SPS + // inline on keyframes. + if !self.avc1 + && frame.keyframe + && let Some(sps) = find_sps(&frame.payload) + { + self.configure_from_sps(&sps)?; + } - let mut catalog = self.catalog.lock(); - if let Some(track) = self.track.take() { - if self.tracks.is_fixed() { - self.track = Some(track); - anyhow::bail!("fixed track cannot be reconfigured"); + if self.config.is_none() { + // A keyframe we still can't configure is undecodable, so bail + // loudly. A non-keyframe before config is a mid-stream-join + // leftover: write it through, and the producer reports + // MissingKeyframe (which a mid-stream join skips). + if frame.keyframe { + return Err(Error::NotInitialized.into()); + } } - tracing::debug!(name = ?track.name, "reinitializing H.264 track"); - catalog.video.renditions.remove(&track.name); - } - let track = self.tracks.create()?; - tracing::debug!(name = ?track.name, ?config, "starting H.264 track"); - catalog.video.renditions.insert(track.name.clone(), config.clone()); - self.config = Some(config); - self.track = - Some(crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy).with_lenient_start()); - Ok(()) - } + let pts = frame.timestamp; + self.track.write(frame)?; - /// Finish the track, flushing any buffered data. - pub fn finish(&mut self) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.finish()?; - Ok(()) - } - - /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.seek(sequence)?; + if let Some(jitter) = self.jitter.observe(pts) { + self.rendition + .update(|c| c.jitter = moq_net::Time::try_from(jitter).ok()); + } + } Ok(()) } - fn pts(&mut self, hint: Option) -> anyhow::Result { - if let Some(pts) = hint { - return Ok(pts); - } - let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); - Ok(crate::container::Timestamp::from_micros( - zero.elapsed().as_micros() as u64 - )?) + /// Publish split frames, resolving the avc3 config from the first keyframe's + /// inline SPS and refining the catalog jitter as it goes. + pub fn decode(&mut self, frames: impl IntoIterator) -> Result<()> { + self.write_frames(frames) } } -impl Drop for Import { - fn drop(&mut self) { - if let Some(track) = self.track.take() { - tracing::debug!(name = ?track.name, "ending H.264 track"); - self.catalog.lock().video.renditions.remove(&track.name); - } - } +/// Detect the avc1 wire shape from leading bytes: a 3- or 4-byte Annex-B start +/// code means avc3, otherwise an AVCDecoderConfigurationRecord (avc1). An empty +/// buffer is avc3: there's no avcC to parse, and avc3 self-initializes from the +/// first keyframe (e.g. moqsink hands an empty init for inline-SPS/PPS streams). +fn detect_avc1(bytes: &[u8]) -> bool { + !(bytes.is_empty() || matches!(bytes, [0, 0, 1, ..]) || matches!(bytes, [0, 0, 0, 1, ..])) } -/// Detect the wire shape from leading bytes: a 3- or 4-byte Annex-B start -/// code means avc3, otherwise an AVCDecoderConfigurationRecord (avc1). -fn detect_mode(bytes: &[u8]) -> Mode { - let three_byte = matches!(bytes, [0, 0, 1, ..]); - let four_byte = matches!(bytes, [0, 0, 0, 1, ..]); - if three_byte || four_byte { - Mode::Avc3 - } else { - Mode::Avc1 - } +fn is_sps(nal: &[u8]) -> bool { + nal.first().is_some_and(|h| h & 0x1f == NAL_TYPE_SPS) } -/// Detect if an avc1-shaped (length-prefixed) buffer contains an IDR slice. -fn avc1_is_keyframe(data: &[u8], length_size: usize) -> bool { - let mut offset = 0; - while offset + length_size <= data.len() { - let nal_len = match length_size { - 1 => data[offset] as usize, - 2 => u16::from_be_bytes([data[offset], data[offset + 1]]) as usize, - 3 => u32::from_be_bytes([0, data[offset], data[offset + 1], data[offset + 2]]) as usize, - 4 => u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]) as usize, - _ => return false, - }; - offset += length_size; - if offset + nal_len > data.len() { - break; - } - if nal_len > 0 && data[offset] & 0x1f == 5 { - return true; // IDR slice +/// Find the first SPS NAL in an Annex-B payload, if any. +fn find_sps(payload: &[u8]) -> Option { + let mut buf = Bytes::copy_from_slice(payload); + let mut nals = NalIterator::new(&mut buf); + while let Some(Ok(nal)) = nals.next() { + if is_sps(&nal) { + return Some(nal); } - offset += nal_len; } - false + nals.flush().ok().flatten().filter(|nal| is_sps(nal)) } #[cfg(test)] mod tests { - use super::*; + use bytes::BytesMut; - #[test] - fn detect_mode_avc1_avcc_buffer() { - // AVCDecoderConfigurationRecord starts with configurationVersion = 1, profile, ... - // First byte is 0x01, definitely not a start code. - let avcc: &[u8] = &[ - 0x01, 0x42, 0xc0, 0x1f, 0xff, 0xe1, 0x00, 0x06, 0x67, 0x42, 0xc0, 0x1f, 0xde, 0xad, - ]; - assert_eq!(detect_mode(avcc), Mode::Avc1); - } - - #[test] - fn detect_mode_avc3_3byte_start_code() { - let nals: &[u8] = &[0x00, 0x00, 0x01, 0x67, 0x42, 0xc0, 0x1f]; - assert_eq!(detect_mode(nals), Mode::Avc3); - } + use super::*; + use crate::codec::h264::Split; - #[test] - fn detect_mode_avc3_4byte_start_code() { - let nals: &[u8] = &[0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xc0, 0x1f]; - assert_eq!(detect_mode(nals), Mode::Avc3); + fn setup(name: &str) -> (moq_net::TrackProducer, crate::catalog::Producer) { + let mut broadcast = moq_net::Broadcast::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let track = broadcast.create_track(moq_net::Track::new(name)).unwrap(); + (track, catalog) } - /// Auto-detect routes an avcC initializer into the avc1 path and stores - /// it in the catalog `description`. + /// An avcC initializer resolves a config with the avcC stored as `description`. #[tokio::test(start_paused = true)] - async fn auto_detect_avc1_lands_in_catalog() { - // Minimal AVCDecoderConfigurationRecord: version(1) profile(0x42) compat(0xc0) level(0x1f) - // length_size_minus_one + 0xfc | 3 = 0xff - // reserved | num_sps = 0xe1 - // sps_len = 4, sps bytes (NAL header 0x67 + profile/level for parsing). + async fn initialize_avc1_lands_in_catalog() { let sps_nal = [0x67, 0x42, 0xc0, 0x1f]; let mut avcc = vec![0x01, 0x42, 0xc0, 0x1f, 0xff, 0xe1, 0x00, sps_nal.len() as u8]; avcc.extend_from_slice(&sps_nal); avcc.extend_from_slice(&[0x01, 0x00, 0x04, 0x68, 0xce, 0x3c, 0x80]); // num_pps + pps - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); - let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - - let mut importer = Import::new(producer, catalog.clone()); - let mut buf = bytes::BytesMut::from(avcc.as_slice()); - importer.initialize(&mut buf).expect("initialize avc1"); + let (track, catalog) = setup("video"); + let mut import = Import::new(track, catalog.clone()); + // initialize() must not consume the buffer (the split owns the consume). + let buf = bytes::BytesMut::from(avcc.as_slice()); + import.initialize(&buf).expect("initialize avc1"); + assert_eq!(buf.len(), avcc.len(), "initialize must not consume the buffer"); let snapshot = catalog.snapshot(); - assert_eq!(snapshot.video.renditions.len(), 1); - let cfg = snapshot.video.renditions.values().next().unwrap(); + let cfg = snapshot.video.renditions.get("video").expect("rendition"); let hang::catalog::VideoCodec::H264(h264) = &cfg.codec else { panic!("expected H.264 codec") }; assert!(!h264.inline, "avc1 source should land as inline=false"); assert_eq!(h264.profile, 0x42); assert_eq!(h264.level, 0x1f); - let desc = cfg.description.as_ref().expect("description set"); - assert_eq!(desc.as_ref(), avcc.as_slice()); + assert_eq!(cfg.description.as_ref().expect("description").as_ref(), avcc.as_slice()); } - /// Auto-detect routes an Annex-B initializer into the avc3 path; the - /// catalog rendition reports inline=true and no description. + /// An avc3 stream self-initializes: the config is resolved from the SPS the + /// splitter packages into the first keyframe. #[tokio::test(start_paused = true)] - async fn auto_detect_avc3_lands_in_catalog() { + async fn avc3_self_initializes_from_first_keyframe() { let sps: &[u8] = &[ 0x67, 0x42, 0xc0, 0x1f, 0xda, 0x01, 0x40, 0x16, 0xe9, 0xb8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x07, 0xd0, 0x00, 0x01, 0xd4, 0xc0, 0x80, ]; let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; - let mut annexb = bytes::BytesMut::new(); - annexb.extend_from_slice(&[0, 0, 0, 1]); - annexb.extend_from_slice(sps); - annexb.extend_from_slice(&[0, 0, 0, 1]); - annexb.extend_from_slice(pps); + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + + let mut annexb = BytesMut::new(); + for nal in [sps, pps, idr] { + annexb.extend_from_slice(&[0, 0, 0, 1]); + annexb.extend_from_slice(nal); + } - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); - let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + let mut split = Split::new(); + let (track, catalog) = setup("video"); + let mut import = Import::new(track, catalog.clone()); + assert!( + catalog.snapshot().video.renditions.is_empty(), + "no config before any frame" + ); - let mut importer = Import::new(producer, catalog.clone()); - importer.initialize(&mut annexb).expect("initialize avc3"); + let pts = crate::container::Timestamp::from_micros(0).unwrap(); + let mut frames = split.decode(&annexb, pts).expect("split keyframe"); + frames.extend(split.flush(pts).expect("flush keyframe")); + import.decode(frames).expect("decode keyframe"); let snapshot = catalog.snapshot(); - assert_eq!(snapshot.video.renditions.len(), 1); - let cfg = snapshot.video.renditions.values().next().unwrap(); - let hang::catalog::VideoCodec::H264(h264) = &cfg.codec else { + let h264_cfg = snapshot.video.renditions.get("video").expect("rendition"); + let hang::catalog::VideoCodec::H264(h264) = &h264_cfg.codec else { panic!("expected H.264 codec") }; assert!(h264.inline, "avc3 source should land as inline=true"); - assert!(cfg.description.is_none(), "avc3 has no out-of-band description"); + assert!(h264_cfg.description.is_none(), "avc3 has no out-of-band description"); assert_eq!(h264.profile, sps[1]); assert_eq!(h264.level, sps[3]); } - /// A source that defines two PPS once, then sends a bare IDR (no inline - /// parameter sets): the importer must re-inject BOTH cached PPS on the - /// keyframe, not just the last one. Regression for the multi-PPS collapse. + /// A keyframe that carries no SPS (and no avcC/seed to fall back on) is + /// undecodable, so it's a hard error rather than an uncatalogued frame. #[tokio::test(start_paused = true)] - async fn avc3_reinjects_all_cached_pps_on_keyframe() { - const SC: &[u8] = &[0, 0, 0, 1]; - // A real, parseable SPS so init_from_sps can read the resolution. - let sps: &[u8] = &[ - 0x67, 0x42, 0xc0, 0x1f, 0xda, 0x01, 0x40, 0x16, 0xe9, 0xb8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x07, 0xd0, 0x00, - 0x01, 0xd4, 0xc0, 0x80, - ]; - let pps0: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; - let pps1: &[u8] = &[0x68, 0xce, 0x3c, 0x81]; - let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; - - let annexb = |nals: &[&[u8]]| { - let mut buf = bytes::BytesMut::new(); - for nal in nals { - buf.extend_from_slice(SC); - buf.extend_from_slice(nal); - } - buf - }; - - let mut producer = moq_net::Broadcast::new().produce(); - let consumer = producer.consume(); - let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let mut importer = Import::new(producer, catalog.clone()) - .with_mode(Mode::Avc3) - .expect("avc3 mode"); - let name = importer.track().unwrap().name.clone(); - - // First AU defines both PPS inline; the second is a bare IDR. - importer - .decode_frame( - &mut annexb(&[sps, pps0, pps1, idr]), - Some(crate::container::Timestamp::from_millis(0).unwrap()), - ) - .unwrap(); - importer - .decode_frame( - &mut annexb(&[idr]), - Some(crate::container::Timestamp::from_millis(40).unwrap()), - ) - .unwrap(); - importer.finish().unwrap(); - - let track = consumer.subscribe_track(&moq_net::Track::new(name)).unwrap(); - let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy); - let mut frames = Vec::new(); - while let Ok(Ok(Some(frame))) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await - { - frames.push(frame); - } - - assert_eq!(frames.len(), 2, "expected two keyframes"); - // The bare IDR keyframe must carry SPS + both PPS, re-injected in order. - assert_eq!(frames[1].payload.as_ref(), annexb(&[sps, pps0, pps1, idr]).as_ref()); + async fn keyframe_without_sps_errors() { + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; // IDR slice, no inline SPS + let mut annexb = BytesMut::new(); + annexb.extend_from_slice(&[0, 0, 0, 1]); + annexb.extend_from_slice(idr); + + let mut split = Split::new(); + let (track, catalog) = setup("video"); + let mut import = Import::new(track, catalog); + + let pts = crate::container::Timestamp::from_micros(0).unwrap(); + let mut frames = split.decode(&annexb, pts).expect("split keyframe"); + frames.extend(split.flush(pts).expect("flush keyframe")); + let err = import + .decode(frames) + .expect_err("an unconfigurable keyframe must error"); + assert!(matches!(err, crate::Error::H264(Error::NotInitialized)), "got {err:?}"); } - /// A keyframe that presents a smaller parameter set than a prior one reinits - /// the retained set: the dropped PPS must not be re-injected on later bare - /// keyframes. + /// A non-keyframe before the first keyframe has no group to anchor it, so the + /// producer surfaces MissingKeyframe (which a mid-stream join skips). It must + /// not silently abort the import. #[tokio::test(start_paused = true)] - async fn avc3_reinit_drops_superseded_pps_on_keyframe() { - const SC: &[u8] = &[0, 0, 0, 1]; - let sps: &[u8] = &[ - 0x67, 0x42, 0xc0, 0x1f, 0xda, 0x01, 0x40, 0x16, 0xe9, 0xb8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x07, 0xd0, 0x00, - 0x01, 0xd4, 0xc0, 0x80, - ]; - let pps0: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; - let pps1: &[u8] = &[0x68, 0xce, 0x3c, 0x81]; - let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; - - let annexb = |nals: &[&[u8]]| { - let mut buf = bytes::BytesMut::new(); - for nal in nals { - buf.extend_from_slice(SC); - buf.extend_from_slice(nal); - } - buf - }; - - let mut producer = moq_net::Broadcast::new().produce(); - let consumer = producer.consume(); - let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let mut importer = Import::new(producer, catalog.clone()) - .with_mode(Mode::Avc3) - .expect("avc3 mode"); - let name = importer.track().unwrap().name.clone(); - - // GOP 1 defines both PPS; GOP 2 redefines the set with only PPS 0; GOP 3 is - // a bare IDR that must re-inject the reduced set, not the dropped PPS 1. - let times = [0u64, 40, 80]; - let gops: [&[&[u8]]; 3] = [&[sps, pps0, pps1, idr], &[sps, pps0, idr], &[idr]]; - for (gop, t) in gops.iter().zip(times) { - importer - .decode_frame( - &mut annexb(gop), - Some(crate::container::Timestamp::from_millis(t).unwrap()), - ) - .unwrap(); - } - importer.finish().unwrap(); - - let track = consumer.subscribe_track(&moq_net::Track::new(name)).unwrap(); - let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy); - let mut frames = Vec::new(); - while let Ok(Ok(Some(frame))) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await - { - frames.push(frame); - } - - assert_eq!(frames.len(), 3, "expected three keyframes"); - // The bare third keyframe re-injects only the surviving SPS + PPS 0. - assert_eq!(frames[2].payload.as_ref(), annexb(&[sps, pps0, idr]).as_ref()); + async fn delta_before_init_reports_missing_keyframe() { + let pslice: &[u8] = &[0x61, 0xe0, 0x12, 0x34]; // non-IDR slice + let mut annexb = BytesMut::new(); + annexb.extend_from_slice(&[0, 0, 0, 1]); + annexb.extend_from_slice(pslice); + + let mut split = Split::new(); + let (track, catalog) = setup("video"); + let mut import = Import::new(track, catalog.clone()); + + let pts = crate::container::Timestamp::from_micros(0).unwrap(); + let mut frames = split.decode(&annexb, pts).expect("split delta"); + frames.extend(split.flush(pts).expect("flush delta")); + let err = import + .decode(frames) + .expect_err("a delta before any keyframe must report MissingKeyframe"); + assert!(matches!(err, crate::Error::MissingKeyframe(_)), "got {err:?}"); + assert!( + catalog.snapshot().video.renditions.is_empty(), + "no config yet, so no catalog" + ); } } - -#[derive(Debug, Clone, Copy, PartialEq, Eq, num_enum::TryFromPrimitive)] -#[repr(u8)] -enum Avc3NalType { - Unspecified = 0, - NonIdrSlice = 1, - DataPartitionA = 2, - DataPartitionB = 3, - DataPartitionC = 4, - IdrSlice = 5, - Sei = 6, - Sps = 7, - Pps = 8, - Aud = 9, - EndOfSeq = 10, - EndOfStream = 11, - Filler = 12, - SpsExt = 13, - Prefix = 14, - SubsetSps = 15, - DepthParameterSet = 16, -} diff --git a/rs/moq-mux/src/codec/h264/mod.rs b/rs/moq-mux/src/codec/h264/mod.rs index 0185159de..32bd62921 100644 --- a/rs/moq-mux/src/codec/h264/mod.rs +++ b/rs/moq-mux/src/codec/h264/mod.rs @@ -3,20 +3,128 @@ //! Parses SPS NAL units and AVCDecoderConfigurationRecord blobs into //! catalog-ready fields. The [`Avc1`] transmuxer rewrites Annex-B input //! (inline SPS/PPS) as length-prefixed NALU + out-of-band avcC, which is -//! what every CMAF and MKV consumer expects. [`Import`] is the importer; -//! it auto-detects either wire shape from the leading bytes. - +//! what every CMAF and MKV consumer expects. [`Export`] subscribes to a +//! catalog-narrowed H.264 rendition and emits an Annex-B elementary +//! stream; [`Split`] does the byte-level framing for the Annex-B (avc3) +//! wire shape and [`Import`] is the pure frame publisher that resolves the +//! catalog. avc1 (length-prefixed NALU) has no stream framing; wrap one +//! access unit with `avc1_frame`. + +mod export; mod import; +mod split; +pub use export::*; pub use import::*; +pub use split::*; -use anyhow::Context; use bytes::{Buf, BufMut, Bytes, BytesMut}; // H.264 NAL unit types (ISO/IEC 14496-10 §7.4.1). const NAL_TYPE_SPS: u8 = 7; const NAL_TYPE_PPS: u8 = 8; +/// Wrap one avc1 (length-prefixed NALU) access unit as a single +/// [`Frame`](crate::container::Frame), with the keyframe flag set when it +/// carries an IDR slice (NAL type 5). +/// +/// avc1 is not a stream: each access unit arrives whole with its NALU +/// `length_size` known out-of-band from the avcC (`super::Avcc::parse(avcc).length_size`). +/// The payload is passed through verbatim. +pub(crate) fn avc1_frame( + data: &[u8], + length_size: usize, + pts: crate::container::Timestamp, +) -> crate::Result { + Ok(crate::container::Frame { + timestamp: pts, + payload: data.to_vec().into(), + keyframe: avc1_is_keyframe(data, length_size), + duration: None, + }) +} + +/// Detect whether an avc1-shaped (length-prefixed) buffer contains an IDR slice. +fn avc1_is_keyframe(data: &[u8], length_size: usize) -> bool { + let mut offset = 0; + while offset + length_size <= data.len() { + let nal_len = match length_size { + 1 => data[offset] as usize, + 2 => u16::from_be_bytes([data[offset], data[offset + 1]]) as usize, + 3 => u32::from_be_bytes([0, data[offset], data[offset + 1], data[offset + 2]]) as usize, + 4 => u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]) as usize, + _ => return false, + }; + offset += length_size; + if offset + nal_len > data.len() { + break; + } + if nal_len > 0 && data[offset] & 0x1f == 5 { + return true; // IDR slice + } + offset += nal_len; + } + false +} + +/// H.264 parsing and transform errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("SPS NAL too short")] + SpsTooShort, + + #[error("failed to parse SPS")] + SpsParse, + + #[error("AVCDecoderConfigurationRecord too short")] + AvccTooShort, + + #[error("AVCDecoderConfigurationRecord truncated")] + AvccTruncated, + + #[error("avc1 description for rendition {name:?} is missing SPS or PPS (sps={sps}, pps={pps})")] + MissingParamSets { name: String, sps: usize, pps: usize }, + + #[error("SPS too large for avcC length field ({0} > {max})", max = u16::MAX)] + SpsTooLarge(usize), + + #[error("PPS too large for avcC length field ({0} > {max})", max = u16::MAX)] + PpsTooLarge(usize), + + #[error("avcC requires at least one SPS")] + MissingSps, + + #[error("too many SPS for avcC ({0} > 31)")] + TooManySps(usize), + + #[error("too many PPS for avcC ({0} > 255)")] + TooManyPps(usize), + + #[error("NAL too large for 4-byte length prefix")] + NalTooLarge, + + #[error("NAL unit is too short")] + NalTooShort, + + #[error("forbidden zero bit is not zero")] + ForbiddenZeroBit, + + #[error("not initialized")] + NotInitialized, + + #[error("avc3 track not created")] + Avc3TrackNotCreated, + + #[error("missing timestamp")] + MissingTimestamp, + + #[error("annexb: {0}")] + Annexb(#[from] crate::codec::annexb::Error), +} + +pub type Result = std::result::Result; + /// Parsed H.264 SPS (Sequence Parameter Set) NAL. /// /// Wraps [`h264_parser::Sps`] with the codec-config fields that the hang @@ -33,10 +141,12 @@ pub struct Sps { impl Sps { /// Parse an SPS NAL unit. - pub fn parse(nal: &[u8]) -> anyhow::Result { - anyhow::ensure!(nal.len() >= 4, "SPS NAL too short"); + pub fn parse(nal: &[u8]) -> Result { + if nal.len() < 4 { + return Err(Error::SpsTooShort); + } let rbsp = h264_parser::nal::ebsp_to_rbsp(&nal[1..]); - let sps = h264_parser::Sps::parse(&rbsp).context("failed to parse SPS")?; + let sps = h264_parser::Sps::parse(&rbsp).map_err(|_| Error::SpsParse)?; Ok(Self { profile: sps.profile_idc, constraints: pack_constraint_flags(&sps), @@ -55,8 +165,11 @@ impl Sps { #[derive(Debug, Clone)] #[non_exhaustive] pub struct Avcc { + /// AVC profile indication (`profile_idc`) from the record. pub profile: u8, + /// Packed constraint-set flags byte from the record. pub constraints: u8, + /// AVC level indication (`level_idc`) from the record. pub level: u8, /// NALU length size in bytes (typically 4). pub length_size: usize, @@ -71,8 +184,10 @@ pub struct Avcc { impl Avcc { /// Parse an AVCDecoderConfigurationRecord buffer. - pub fn parse(avcc: &[u8]) -> anyhow::Result { - anyhow::ensure!(avcc.len() >= 7, "AVCDecoderConfigurationRecord too short"); + pub fn parse(avcc: &[u8]) -> Result { + if avcc.len() < 7 { + return Err(Error::AvccTooShort); + } let profile = avcc[1]; let constraints = avcc[2]; @@ -80,14 +195,15 @@ impl Avcc { let length_size = (avcc[4] & 0x03) as usize + 1; let num_sps = (avcc[5] & 0x1f) as usize; - let mut sps = Vec::with_capacity(num_sps); - let mut pos = read_param_set_array(avcc, 6, num_sps, &mut sps)?; + let mut pos = 6; + let sps = read_param_sets(avcc, &mut pos, num_sps)?; - anyhow::ensure!(avcc.len() > pos, "AVCDecoderConfigurationRecord truncated"); + if avcc.len() <= pos { + return Err(Error::AvccTruncated); + } let num_pps = avcc[pos] as usize; pos += 1; - let mut pps = Vec::with_capacity(num_pps); - read_param_set_array(avcc, pos, num_pps, &mut pps)?; + let pps = read_param_sets(avcc, &mut pos, num_pps)?; // Resolution from the first parseable SPS. let (mut coded_width, mut coded_height) = (None, None); @@ -126,31 +242,27 @@ fn pack_constraint_flags(sps: &h264_parser::Sps) -> u8 { /// are read from the first SPS. A stream may legitimately carry several distinct /// SPS/PPS (slices reference them by id), so the record holds an ordered list of /// each rather than a single one. -pub(crate) fn build_avcc(sps_nals: &[Bytes], pps_nals: &[Bytes]) -> anyhow::Result { - let first_sps = sps_nals.first().context("avcC requires at least one SPS")?; - anyhow::ensure!(first_sps.len() >= 4, "SPS NAL too short"); +pub(crate) fn build_avcc(sps_nals: &[Bytes], pps_nals: &[Bytes]) -> Result { + let first_sps = sps_nals.first().ok_or(Error::MissingSps)?; + if first_sps.len() < 4 { + return Err(Error::SpsTooShort); + } // numOfSequenceParameterSets is a 5-bit field, numOfPictureParameterSets a byte. - anyhow::ensure!( - sps_nals.len() <= 0x1f, - "too many SPS for avcC ({} > 31)", - sps_nals.len() - ); - anyhow::ensure!( - pps_nals.len() <= u8::MAX as usize, - "too many PPS for avcC ({} > 255)", - pps_nals.len() - ); - for (label, nal) in sps_nals - .iter() - .map(|n| ("SPS", n)) - .chain(pps_nals.iter().map(|n| ("PPS", n))) - { - anyhow::ensure!( - nal.len() <= u16::MAX as usize, - "{label} too large for avcC length field ({} > {})", - nal.len(), - u16::MAX - ); + if sps_nals.len() > 0x1f { + return Err(Error::TooManySps(sps_nals.len())); + } + if pps_nals.len() > u8::MAX as usize { + return Err(Error::TooManyPps(pps_nals.len())); + } + for sps in sps_nals { + if sps.len() > u16::MAX as usize { + return Err(Error::SpsTooLarge(sps.len())); + } + } + for pps in pps_nals { + if pps.len() > u16::MAX as usize { + return Err(Error::PpsTooLarge(pps.len())); + } } let profile_idc = first_sps[1]; @@ -177,6 +289,27 @@ pub(crate) fn build_avcc(sps_nals: &[Bytes], pps_nals: &[Bytes]) -> anyhow::Resu Ok(out.freeze()) } +/// Read `count` length-prefixed (u16) NAL units from `buf` starting at `*pos`, +/// advancing `*pos` past the last one. All arithmetic is checked so malformed +/// configs surface as errors rather than panics. +fn read_param_sets(buf: &[u8], pos: &mut usize, count: usize) -> Result> { + let mut out = Vec::with_capacity(count); + for _ in 0..count { + let after_len = pos.checked_add(2).ok_or(Error::AvccTruncated)?; + if buf.len() < after_len { + return Err(Error::AvccTruncated); + } + let len = u16::from_be_bytes([buf[*pos], buf[*pos + 1]]) as usize; + let after_nal = after_len.checked_add(len).ok_or(Error::AvccTruncated)?; + if buf.len() < after_nal { + return Err(Error::AvccTruncated); + } + out.push(Bytes::copy_from_slice(&buf[after_len..after_nal])); + *pos = after_nal; + } + Ok(out) +} + /// Extract the parameter-set NALs (SPS then PPS) and the NALU length size from /// an AVCDecoderConfigurationRecord. The inverse of [`build_avcc`]; used to /// re-emit out-of-band avc1 parameter sets as inline Annex-B (e.g. for MPEG-TS). @@ -257,7 +390,7 @@ impl Avc1 { /// - `Ok(None)` if the input contained only parameter sets and the /// transform is still waiting for slice NALs (avcC may have been built /// as a side effect). - pub fn transform(&mut self, payload: Bytes) -> anyhow::Result> { + pub fn transform(&mut self, payload: Bytes) -> Result> { // Parse Annex-B NALs, collect this frame's SPS/PPS, length-prefix the // rest. NalIterator advances the Bytes cursor; the trailing NAL has to be // pulled separately via flush(). @@ -272,7 +405,7 @@ impl Avc1 { loop { let nal = match nal_iter.next() { Some(Ok(n)) => n, - Some(Err(e)) => return Err(e), + Some(Err(e)) => return Err(e.into()), None => break, }; if process_nal(&nal, &mut out, &mut frame_sps, &mut frame_pps)? { @@ -310,7 +443,7 @@ impl Avc1 { Ok(Some(out.freeze())) } - fn rebuild_avcc(&mut self) -> anyhow::Result<()> { + fn rebuild_avcc(&mut self) -> Result<()> { if self.sps.is_empty() || self.pps.is_empty() { return Ok(()); } @@ -327,7 +460,7 @@ fn process_nal( out: &mut BytesMut, frame_sps: &mut Vec, frame_pps: &mut Vec, -) -> anyhow::Result { +) -> Result { if nal.is_empty() { return Ok(false); } @@ -341,7 +474,7 @@ fn process_nal( Ok(false) } _ => { - let len = u32::try_from(nal.len()).context("NAL too large for 4-byte length prefix")?; + let len = u32::try_from(nal.len()).map_err(|_| Error::NalTooLarge)?; out.extend_from_slice(&len.to_be_bytes()); out.extend_from_slice(nal); Ok(true) @@ -364,6 +497,32 @@ mod tests { buf.freeze() } + /// avc1: a length-prefixed access unit with an IDR slice wraps as one keyframe; + /// the payload is passed through verbatim. + #[test] + fn avc1_frame_keyframe() { + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + let mut au = BytesMut::new(); + au.extend_from_slice(&(idr.len() as u32).to_be_bytes()); + au.extend_from_slice(idr); + + let frame = avc1_frame(&au, 4, crate::container::Timestamp::from_micros(0).unwrap()).unwrap(); + assert!(frame.keyframe); + assert_eq!(frame.payload[4..], *idr); + } + + /// avc1: a length-prefixed access unit with a non-IDR slice is a delta frame. + #[test] + fn avc1_frame_delta() { + let pslice: &[u8] = &[0x61, 0xe0, 0x12, 0x34]; + let mut au = BytesMut::new(); + au.extend_from_slice(&(pslice.len() as u32).to_be_bytes()); + au.extend_from_slice(pslice); + + let frame = avc1_frame(&au, 4, crate::container::Timestamp::from_micros(0).unwrap()).unwrap(); + assert!(!frame.keyframe); + } + #[test] fn avc3_strips_sps_pps_and_builds_avcc() { let sps = &[0x67, 0x42, 0xc0, 0x1f, 0xde][..]; @@ -401,20 +560,6 @@ mod tests { assert_eq!(params[1], pps); } - #[test] - fn avcc_parse_separates_sps_and_pps() { - let sps = Bytes::from_static(&[0x67, 0x42, 0xc0, 0x1f, 0xde]); - let pps0 = Bytes::from_static(&[0x68, 0xce, 0x3c, 0x80]); - let pps1 = Bytes::from_static(&[0x68, 0xce, 0x3c, 0x81]); - - let avcc = build_avcc(std::slice::from_ref(&sps), &[pps0.clone(), pps1.clone()]).unwrap(); - let parsed = Avcc::parse(&avcc).unwrap(); - - assert_eq!(parsed.length_size, 4); - assert_eq!(parsed.sps, vec![sps]); - assert_eq!(parsed.pps, vec![pps0, pps1]); - } - #[test] fn build_avcc_carries_multiple_pps() { // A source with one SPS and two PPS (ids 0 and 1): the avcC must keep both, diff --git a/rs/moq-mux/src/codec/h264/split.rs b/rs/moq-mux/src/codec/h264/split.rs new file mode 100644 index 000000000..0a69129ec --- /dev/null +++ b/rs/moq-mux/src/codec/h264/split.rs @@ -0,0 +1,381 @@ +//! H.264 Annex-B stream splitter. +//! +//! [`Split`] turns a raw H.264 Annex-B byte stream (inline SPS/PPS, the "avc3" +//! wire shape) into [`crate::container::Frame`]s. It finds access-unit +//! boundaries, caches SPS/PPS and re-inserts them ahead of each keyframe so +//! every keyframe is self-contained, and stamps wall-clock timestamps when the +//! caller has none (stdin). +//! +//! It is deliberately dumb: framing and structural parsing only. It owns no +//! track, catalog, or codec config (no [`VideoConfig`](hang::catalog::VideoConfig)). +//! The importer parses the codec config out of the frames it emits. +//! +//! avc1 (length-prefixed NALU + out-of-band avcC) is not a stream and has no +//! splitter; wrap one access unit with `super::avc1_frame`. + +use bytes::{Bytes, BytesMut}; + +use super::Error; +use crate::Result; +use crate::codec::annexb::{NalIterator, START_CODE}; + +/// H.264 Annex-B stream splitter: bytes in, [`Frame`](crate::container::Frame)s out. +/// +/// Feed bytes via [`decode`](Self::decode) (unknown frame boundaries, e.g. +/// stdin); call [`flush`](Self::flush) to emit the final in-flight access unit. +/// SPS/PPS seen inline are cached and re-inserted ahead of each keyframe so each +/// keyframe is self-contained. +pub struct Split { + /// Bytes carried over between calls: complete NALs are parsed out on each + /// [`decode`](Self::decode), leaving the in-flight (final, not-yet-terminated) + /// NAL here until the next start code arrives or [`flush`](Self::flush) drains it. + tail: BytesMut, + current: Avc3Frame, + /// Retained SPS NALs from the latest keyframe that carried them, re-injected + /// on bare keyframes. Replaced (not accumulated) when a keyframe presents a + /// different set, so a mid-stream reinit drops the superseded ones. + sps: Vec, + /// Retained PPS NALs. A keyframe may carry several (slices reference them by + /// id); all are kept and re-injected, but a new GOP's set supersedes them. + pps: Vec, + zero: Option, + pending: Vec, +} + +#[derive(Default)] +struct Avc3Frame { + chunks: BytesMut, + contains_idr: bool, + contains_slice: bool, + /// SPS NALs already inline in this access unit, so re-injection skips them. + sps_seen: Vec, + /// PPS NALs already inline in this access unit. + pps_seen: Vec, +} + +impl Default for Split { + fn default() -> Self { + Self::new() + } +} + +impl Split { + /// A fresh splitter with an empty parameter-set cache. + pub fn new() -> Self { + Self { + tail: BytesMut::new(), + current: Avc3Frame::default(), + sps: Vec::new(), + pps: Vec::new(), + zero: None, + pending: Vec::new(), + } + } + + /// Decode a buffer where frame boundaries are unknown, returning the access + /// units it can complete. The leading start code of the *next* access unit is + /// what signals the previous one is complete, so the final NAL of the in-flight + /// access unit stays buffered until the next call (or [`flush`](Self::flush)). + /// The buffer is fully consumed. + pub fn decode( + &mut self, + data: &[u8], + pts: impl Into>, + ) -> Result> { + let pts = self.pts(pts.into())?; + self.tail.extend_from_slice(data); + // Iterate complete NALs out of `tail`, leaving the trailing (in-flight) NAL + // (with its start code) buffered for the next call or `flush`. + let nals = NalIterator::new(&mut self.tail); + let mut parsed = Vec::new(); + for nal in nals { + parsed.push(nal?); + } + for nal in parsed { + self.decode_nal(nal, pts)?; + } + Ok(std::mem::take(&mut self.pending)) + } + + /// Emit the in-flight access unit, if any. Call after the last + /// [`decode`](Self::decode) when a caller handed over a complete access unit + /// (or at end of stream) so the final NAL isn't left buffered. + pub fn flush( + &mut self, + pts: impl Into>, + ) -> Result> { + let pts = self.pts(pts.into())?; + if let Some(nal) = NalIterator::new(&mut self.tail).flush()? { + self.decode_nal(nal, pts)?; + } + self.tail.clear(); + self.maybe_start_frame(pts)?; + Ok(std::mem::take(&mut self.pending)) + } + + fn decode_nal(&mut self, nal: Bytes, pts: crate::container::Timestamp) -> Result<()> { + let header = nal.first().ok_or(Error::NalTooShort)?; + let forbidden_zero_bit = (header >> 7) & 1; + if forbidden_zero_bit != 0 { + return Err(Error::ForbiddenZeroBit.into()); + } + + let nal_unit_type = header & 0b11111; + let nal_type = Avc3NalType::try_from(nal_unit_type).ok(); + + match nal_type { + Some(Avc3NalType::Sps) => { + self.maybe_start_frame(pts)?; + // Track only what this AU carries; the retained set is reconciled at + // the keyframe so a new GOP's set replaces (not accumulates onto) it. + crate::codec::annexb::push_distinct(&mut self.current.sps_seen, &nal); + } + Some(Avc3NalType::Pps) => { + self.maybe_start_frame(pts)?; + crate::codec::annexb::push_distinct(&mut self.current.pps_seen, &nal); + } + Some(Avc3NalType::Aud) | Some(Avc3NalType::Sei) => { + self.maybe_start_frame(pts)?; + } + Some(Avc3NalType::IdrSlice) => { + // Adopt this keyframe's inline set (dropping any the new GOP no longer + // uses), or re-inject the retained set if the keyframe carried none. + crate::codec::annexb::reconcile_keyframe_params( + &mut self.current.chunks, + &mut self.sps, + &mut self.current.sps_seen, + ); + crate::codec::annexb::reconcile_keyframe_params( + &mut self.current.chunks, + &mut self.pps, + &mut self.current.pps_seen, + ); + self.current.contains_idr = true; + self.current.contains_slice = true; + } + Some(Avc3NalType::NonIdrSlice) + | Some(Avc3NalType::DataPartitionA) + | Some(Avc3NalType::DataPartitionB) + | Some(Avc3NalType::DataPartitionC) => { + if nal.get(1).ok_or(Error::NalTooShort)? & 0x80 != 0 { + self.maybe_start_frame(pts)?; + } + self.current.contains_slice = true; + } + _ => {} + } + + tracing::trace!(kind = ?nal_type, "parsed NAL"); + + self.current.chunks.extend_from_slice(&START_CODE); + self.current.chunks.extend_from_slice(&nal); + Ok(()) + } + + fn maybe_start_frame(&mut self, pts: crate::container::Timestamp) -> Result<()> { + if !self.current.contains_slice { + return Ok(()); + } + let payload = std::mem::take(&mut self.current.chunks).freeze(); + let keyframe = self.current.contains_idr; + self.current.contains_idr = false; + self.current.contains_slice = false; + self.current.sps_seen.clear(); + self.current.pps_seen.clear(); + + self.pending.push(crate::container::Frame { + timestamp: pts, + payload, + keyframe, + duration: None, + }); + Ok(()) + } + + /// Drop any in-flight access unit. + /// + /// Pre-reset NALs would otherwise leak into a later frame with the wrong + /// timestamp. The parameter-set cache is kept so subsequent keyframes stay + /// self-contained. + pub fn reset(&mut self) { + self.current = Avc3Frame::default(); + self.tail.clear(); + } + + fn pts(&mut self, hint: Option) -> Result { + if let Some(pts) = hint { + return Ok(pts); + } + let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); + Ok(crate::container::Timestamp::from_micros( + zero.elapsed().as_micros() as u64 + )?) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, num_enum::TryFromPrimitive)] +#[repr(u8)] +enum Avc3NalType { + Unspecified = 0, + NonIdrSlice = 1, + DataPartitionA = 2, + DataPartitionB = 3, + DataPartitionC = 4, + IdrSlice = 5, + Sei = 6, + Sps = 7, + Pps = 8, + Aud = 9, + EndOfSeq = 10, + EndOfStream = 11, + Filler = 12, + SpsExt = 13, + Prefix = 14, + SubsetSps = 15, + DepthParameterSet = 16, +} + +#[cfg(test)] +mod tests { + use super::*; + + const SC4: &[u8] = &[0, 0, 0, 1]; + + fn annexb(nals: &[&[u8]]) -> BytesMut { + let mut buf = BytesMut::new(); + for nal in nals { + buf.extend_from_slice(SC4); + buf.extend_from_slice(nal); + } + buf + } + + fn ts() -> crate::container::Timestamp { + crate::container::Timestamp::from_micros(0).unwrap() + } + + /// Decode one complete access unit handed over as a single buffer: `decode` + /// buffers it, `flush` emits it. + fn decode_one( + split: &mut Split, + buf: &mut BytesMut, + pts: crate::container::Timestamp, + ) -> Vec { + let mut frames = split.decode(buf, pts).unwrap(); + frames.extend(split.flush(pts).unwrap()); + frames + } + + /// A keyframe access unit fed as one buffer emits one self-contained frame: + /// SPS+PPS are packaged ahead of the IDR slice and `keyframe` is set. + #[tokio::test(start_paused = true)] + async fn decode_packages_keyframe() { + let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f]; + let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + + let mut split = Split::new(); + let frames = decode_one(&mut split, &mut annexb(&[sps, pps, idr]), ts()); + + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe); + // The payload carries SPS, PPS, then the IDR slice (each start-code prefixed). + assert_eq!(&frames[0].payload[..SC4.len()], SC4); + assert!(frames[0].payload.windows(sps.len()).any(|w| w == sps)); + assert!(frames[0].payload.windows(idr.len()).any(|w| w == idr)); + } + + /// Parameter sets fed up front (as the leading stream bytes) are cached and + /// re-inserted ahead of a later bare IDR, so the keyframe is self-contained + /// even when the stream never repeats its parameter sets inline. + #[tokio::test(start_paused = true)] + async fn params_then_bare_keyframe_self_contained() { + let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f]; + let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + + let mut split = Split::new(); + // The leading SPS/PPS carry no slice, so they complete no frame yet. + assert!(split.decode(&annexb(&[sps, pps]), ts()).unwrap().is_empty()); + + let frames = decode_one(&mut split, &mut annexb(&[idr]), ts()); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe); + assert!(frames[0].payload.windows(sps.len()).any(|w| w == sps)); + assert!(frames[0].payload.windows(pps.len()).any(|w| w == pps)); + } + + /// In streaming mode an access unit completes only once the next one begins + /// (a slice with first_mb_in_slice set). A keyframe AU followed by a P-slice + /// of the next AU completes the keyframe; the P-slice's own AU stays buffered + /// until `flush`. + #[tokio::test(start_paused = true)] + async fn decode_emits_on_next_boundary() { + let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f]; + let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + // P-slice with first_mb_in_slice (byte 1 high bit) set, opening a new AU. + let pslice: &[u8] = &[0x61, 0xe0, 0x12, 0x34]; + // A trailing AUD so the P-slice is a *complete* NAL (it has a following + // start code), letting the keyframe boundary be detected during decode. + let aud: &[u8] = &[0x09, 0x10]; + + let mut split = Split::new(); + let frames = split.decode(&annexb(&[sps, pps, idr, pslice, aud]), ts()).unwrap(); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe); + + // Flushing closes the buffered P-slice AU (the AUD rides along with it). + let tail = split.flush(ts()).unwrap(); + assert_eq!(tail.len(), 1); + assert!(!tail[0].keyframe); + } + + /// A source that defines two PPS once, then sends a bare IDR (no inline + /// parameter sets): both cached PPS must be re-injected on the keyframe, not + /// just the last one. Regression for the multi-PPS collapse. + #[tokio::test(start_paused = true)] + async fn reinjects_all_cached_pps_on_keyframe() { + let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f]; + let pps0: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let pps1: &[u8] = &[0x68, 0xce, 0x3c, 0x81]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + + let mut split = Split::new(); + // First AU defines both PPS inline. + let first = decode_one(&mut split, &mut annexb(&[sps, pps0, pps1, idr]), ts()); + assert_eq!(first.len(), 1); + assert!(first[0].keyframe); + + // Second AU is a bare IDR: the splitter re-injects SPS + both PPS in order. + let second = decode_one(&mut split, &mut annexb(&[idr]), ts()); + assert_eq!(second.len(), 1); + assert!(second[0].keyframe); + assert_eq!( + second[0].payload.as_ref(), + annexb(&[sps, pps0, pps1, idr]).freeze().as_ref() + ); + } + + /// A keyframe that presents a smaller parameter set than a prior one reinits + /// the retained set: the dropped PPS must not be re-injected on later bare + /// keyframes. + #[tokio::test(start_paused = true)] + async fn reinit_drops_superseded_pps_on_keyframe() { + let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f]; + let pps0: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let pps1: &[u8] = &[0x68, 0xce, 0x3c, 0x81]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + + let mut split = Split::new(); + // GOP 1 defines both PPS; GOP 2 redefines with only PPS 0; GOP 3 is a bare + // IDR that must re-inject the reduced set, not the dropped PPS 1. + let _ = decode_one(&mut split, &mut annexb(&[sps, pps0, pps1, idr]), ts()); + let _ = decode_one(&mut split, &mut annexb(&[sps, pps0, idr]), ts()); + let third = decode_one(&mut split, &mut annexb(&[idr]), ts()); + + assert_eq!(third.len(), 1); + assert!(third[0].keyframe); + assert_eq!(third[0].payload.as_ref(), annexb(&[sps, pps0, idr]).freeze().as_ref()); + } +} diff --git a/rs/moq-mux/src/codec/h265/export.rs b/rs/moq-mux/src/codec/h265/export.rs new file mode 100644 index 000000000..308aadf87 --- /dev/null +++ b/rs/moq-mux/src/codec/h265/export.rs @@ -0,0 +1,175 @@ +//! H.265 single-rendition Annex-B exporter. +//! +//! HEVC analogue of [`crate::codec::h264::Export`]. Accepts either a hev1 +//! (Annex-B, parameter sets inline) or hvc1 (length-prefixed + out-of-band +//! hvcC) source and emits a raw Annex-B elementary stream. Timestamps are +//! dropped. + +use std::task::Poll; +use std::time::Duration; + +use bytes::Bytes; +use hang::Catalog; +use hang::catalog::{VideoCodecKind, VideoConfig}; + +use crate::catalog::Stream; +use crate::codec::annexb; +use crate::container::ExportSource; + +/// Single-rendition H.265 Annex-B exporter. +pub struct Export { + broadcast: moq_net::BroadcastConsumer, + catalog: Option, + latency: Duration, + track: Option, +} + +struct H265Track { + name: String, + /// Snapshot of the catalog config we built `source` from. Cached so that + /// a catalog update which keeps the same rendition name but changes the + /// codec config (e.g. a new hvcC) triggers a full rebuild instead of + /// silently reusing a stale `convert`. + config: VideoConfig, + source: ExportSource, + /// `Some` for an hvc1 source: VPS/SPS/PPS prefix prebuilt from the hvcC, + /// and the hvcC length-prefix size. `None` for a hev1 source: Annex-B + /// passes through without conversion. + convert: Option, +} + +struct Hvc1Convert { + length_size: usize, + keyframe_prefix: Bytes, +} + +impl Export { + /// Subscribe to `broadcast` and emit an Annex-B H.265 byte stream. + /// + /// `catalog` is expected to be narrowed to a single H.265 rendition. If + /// multiple H.265 renditions appear in a snapshot, the first by BTreeMap + /// order wins and a warning is logged. + pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { + Self { + broadcast, + catalog: Some(catalog), + latency: Duration::ZERO, + track: None, + } + } + + /// Set the maximum buffering latency for the per-track source. + pub fn with_latency(mut self, latency: Duration) -> Self { + self.latency = latency; + self + } + + pub async fn next(&mut self) -> crate::Result> { + kio::wait(|waiter| self.poll_next(waiter)).await + } + + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + while let Some(catalog) = self.catalog.as_mut() { + match catalog.poll_next(waiter)? { + Poll::Ready(Some(snapshot)) => self.update_catalog(&snapshot.media())?, + Poll::Ready(None) => { + self.catalog = None; + break; + } + Poll::Pending => break, + } + } + + loop { + let Some(track) = self.track.as_mut() else { + if self.catalog.is_none() { + return Poll::Ready(Ok(None)); + } + return Poll::Pending; + }; + + match track.source.poll_read(waiter) { + Poll::Ready(Ok(Some(frame))) => { + let bytes = match &track.convert { + None => frame.payload, + Some(convert) => { + let prefix = frame.keyframe.then(|| convert.keyframe_prefix.as_ref()); + annexb::from_length_prefixed(&frame.payload, convert.length_size, prefix)? + } + }; + if bytes.is_empty() { + continue; + } + return Poll::Ready(Ok(Some(bytes))); + } + Poll::Ready(Ok(None)) => { + self.track = None; + continue; + } + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, + } + } + } + + fn update_catalog(&mut self, catalog: &Catalog) -> crate::Result<()> { + let picked = catalog + .video + .renditions + .iter() + .filter(|(_, c)| c.codec.kind() == VideoCodecKind::H265) + .collect::>(); + + if picked.len() > 1 { + tracing::warn!( + count = picked.len(), + "multiple H.265 renditions in catalog snapshot; using the first by name. \ + Narrow with catalog::Target to pick one explicitly." + ); + } + + let Some((name, config)) = picked.into_iter().next() else { + self.track = None; + return Ok(()); + }; + + if self + .track + .as_ref() + .is_some_and(|t| t.name == *name && t.config == *config) + { + return Ok(()); + } + + let source = ExportSource::for_video_raw(&self.broadcast, name, config, self.latency)?; + let convert = match config.description.as_ref().filter(|d| !d.is_empty()) { + None => None, + Some(hvcc) => { + let params = super::Hvcc::parse(hvcc)?; + if params.vps.is_empty() || params.sps.is_empty() || params.pps.is_empty() { + return Err(super::Error::MissingParamSets { + name: name.clone(), + vps: params.vps.len(), + sps: params.sps.len(), + pps: params.pps.len(), + } + .into()); + } + let prefix = annexb::build_prefix(params.vps.iter().chain(params.sps.iter()).chain(params.pps.iter())); + Some(Hvc1Convert { + length_size: params.length_size, + keyframe_prefix: prefix, + }) + } + }; + + self.track = Some(H265Track { + name: name.clone(), + config: config.clone(), + source, + convert, + }); + + Ok(()) + } +} diff --git a/rs/moq-mux/src/codec/h265/import.rs b/rs/moq-mux/src/codec/h265/import.rs index 981fd8f89..41d81fa0a 100644 --- a/rs/moq-mux/src/codec/h265/import.rs +++ b/rs/moq-mux/src/codec/h265/import.rs @@ -1,91 +1,128 @@ +//! H.265 importer. +//! +//! Publishes H.265 frames (Annex-B, inline VPS/SPS/PPS, the "hev1" shape) on a +//! single moq track and resolves the catalog rendition. Only single-layer +//! streams are supported (VPS is cached but not parsed). +//! +//! The codec config is scanned out of the SPS the splitter packages into the +//! first keyframe (or seeded via [`initialize`](Import::initialize)). A keyframe +//! that can't be configured is an error; non-keyframes before the first config +//! are written through to the producer, which reports +//! [`MissingKeyframe`](crate::container::MissingKeyframe) for a mid-stream join. +//! Annex-B byte parsing lives in [`Split`](super::Split); this type is a pure frame publisher +//! that whoever owns the split drives via [`decode`](Import::decode). + +use bytes::Bytes; +use scuffle_h265::SpsNALUnit; + +use super::{Error, split::nal_unit_type}; +use crate::Result; use crate::catalog::hang::CatalogExt; -use crate::codec::annexb::{NalIterator, START_CODE}; +use crate::codec::annexb::NalIterator; +use crate::container::Frame; use crate::container::jitter::Jitter; -use anyhow::Context; -use bytes::{Buf, Bytes, BytesMut}; -use scuffle_h265::{NALUnitType, SpsNALUnit}; - -/// A decoder for H.265 with inline SPS/PPS. +/// A pure-publisher importer for H.265 with inline VPS/SPS/PPS. /// Only supports single layer streams (VPS is cached but not parsed). +/// +/// Build it with [`new`](Self::new), passing the track producer and the +/// [`catalog::Producer`](crate::catalog::Producer) it publishes into, and feed it +/// frames a [`Split`](super::Split) produced via [`decode`](Self::decode). The +/// catalog rendition fills in lazily once the first SPS is parsed. pub struct Import { - // Where new media tracks come from. - tracks: crate::track_provider::TrackProvider, - - // The catalog being produced. - catalog: crate::catalog::Producer, - - // The track being produced. - track: Option>, - - // Whether the track has been initialized. - // If it changes, then we'll reinitialize with a new track. + track: crate::container::Producer, + rendition: crate::catalog::VideoTrack, config: Option, - - // The current frame being built. - current: Frame, - - // Used to compute wall clock timestamps if needed. - zero: Option, - - // Retained parameter set NALs from the latest keyframe that carried them, - // re-injected before bare keyframes. A keyframe may define several of each - // (slices reference them by id); all are kept, but a new GOP's set supersedes - // them (replace, not accumulate) so a mid-stream reinit drops stale entries. - vps: Vec, - sps: Vec, - pps: Vec, - - // Tracks the minimum frame duration and updates the catalog `jitter` field. + last_sps: Option, jitter: Jitter, } impl Import { - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { + /// Publish on an existing track producer, registering the rendition in `catalog`. + pub fn new(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { + let rendition = catalog.video_track(track.name()); Self { - tracks: crate::track_provider::TrackProvider::unique(broadcast, ".hev1"), - catalog, - track: None, + track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), + rendition, config: None, - current: Default::default(), - zero: None, - vps: Vec::new(), - sps: Vec::new(), - pps: Vec::new(), + last_sps: None, jitter: Jitter::new(), } } - pub fn new_with_track(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { - Self { - tracks: crate::track_provider::TrackProvider::fixed(track), - catalog, - track: None, - config: None, - current: Default::default(), - zero: None, - vps: Vec::new(), - sps: Vec::new(), - pps: Vec::new(), - jitter: Jitter::new(), + /// Resolve the codec config from VPS/SPS/PPS and other non-slice NALs. + /// + /// Resolves the config from any SPS in the buffer. Optional, since the importer + /// also self-initializes from the first keyframe. Takes a read-only slice: the + /// dispatcher-owned [`Split`](super::Split) is what consumes the stream (and seeds + /// its parameter-set cache). + pub fn initialize(&mut self, buf: &[u8]) -> Result<()> { + let mut scan = Bytes::copy_from_slice(buf); + let mut nals = NalIterator::new(&mut scan); + while let Some(nal) = nals.next().transpose()? { + if is_sps(&nal) { + self.configure_from_sps(&nal)?; + } + } + if let Some(nal) = nals.flush()? + && is_sps(&nal) + { + self.configure_from_sps(&nal)?; + } + Ok(()) + } + + /// The MoQ track name this importer publishes on. + pub fn name(&self) -> &str { + self.track.name() + } + + /// A watch-only handle to this track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + self.track.track().demand() + } + + /// Finish the track, flushing the current group. + pub fn finish(&mut self) -> Result<()> { + self.track.finish()?; + Ok(()) + } + + /// Close the current group and open the next one at `sequence`. + pub fn seek(&mut self, sequence: u64) -> Result<()> { + self.track.seek(sequence)?; + Ok(()) + } + + /// Record a frame's reorder delay (`PTS - DTS`) so the catalog `jitter` reflects the + /// B-frame reorder depth (the decode buffer a transmuxer/player must hold). The + /// container supplies this since the elementary stream alone carries no decode time. + pub fn observe_reorder(&mut self, reorder: crate::container::Timestamp) { + if let Some(jitter) = self.jitter.observe_reorder(reorder) { + self.rendition + .update(|c| c.jitter = moq_net::Time::try_from(jitter).ok()); } } - /// Publish (or republish) the catalog rendition for this SPS. Returns true if - /// it changed an existing config (a reconfiguration), so the caller drops the - /// parameter sets tied to the old config. The first SPS is not a reconfiguration. - fn init(&mut self, sps: &SpsNALUnit) -> anyhow::Result { + /// Resolve the config from an inline SPS, updating the rendition in place on a + /// change. + fn configure_from_sps(&mut self, sps_nal: &Bytes) -> Result<()> { + if self.last_sps.as_ref() == Some(sps_nal) { + return Ok(()); + } + + let sps = SpsNALUnit::parse(&mut &sps_nal[..]).map_err(|_| Error::SpsParse)?; let profile = &sps.rbsp.profile_tier_level.general_profile; let vui_data = sps.rbsp.vui_parameters.as_ref().map(VuiData::new).unwrap_or_default(); let mut config = hang::catalog::VideoConfig::new(hang::catalog::H265 { - in_band: true, // We only support `hev1` with inline SPS/PPS for now + in_band: true, // We only support `hev1` with inline VPS/SPS/PPS for now. profile_space: profile.profile_space, profile_idc: profile.profile_idc, profile_compatibility_flags: profile.profile_compatibility_flag.bits().to_be_bytes(), tier_flag: profile.tier_flag, - level_idc: profile.level_idc.context("missing level_idc in SPS")?, - constraint_flags: crate::codec::h265::pack_constraint_flags(profile), + level_idc: profile.level_idc.ok_or(Error::MissingLevelIdc)?, + constraint_flags: super::pack_constraint_flags(profile), }); config.coded_width = Some(sps.rbsp.cropped_width() as u32); config.coded_height = Some(sps.rbsp.cropped_height() as u32); @@ -94,329 +131,77 @@ impl Import { config.display_ratio_height = vui_data.display_ratio_height; config.container = hang::catalog::Container::Legacy; - if let Some(old) = &self.config - && old == &config - { - return Ok(false); - } - - let reconfigured = self.config.is_some(); - // Seed jitter from whatever has accumulated: a dirty start feeds frames before this - // first rendition exists, so those per-frame updates would otherwise be lost. The - // cached `config` stays jitter-free so a later jitter change is not mistaken for a - // codec reconfiguration. - let jitter = self.jitter.current(); - let mut catalog = self.catalog.lock(); + self.last_sps = Some(sps_nal.clone()); - if self.track.is_some() && self.tracks.is_fixed() { - anyhow::bail!("fixed track cannot be reconfigured"); + // A changed SPS just re-mirrors the rendition in place; there are no fixed + // tracks to reject a reconfiguration. + if self.config.as_ref() == Some(&config) { + return Ok(()); } - if let Some(track) = self.track.take() { - tracing::debug!(name = ?track.name, "reinitializing track"); - catalog.video.renditions.remove(&track.name); + tracing::debug!(name = ?self.track.name(), ?config, "starting track"); + self.rendition.set(config.clone()); + // Seed jitter from whatever has accumulated: a dirty start (or a B-frame + // reorder observed via observe_reorder) can feed updates before this + // rendition exists, so those would otherwise be lost on (re)publish. + if let Some(jitter) = self.jitter.current() { + self.rendition + .update(|c| c.jitter = moq_net::Time::try_from(jitter).ok()); } - - let track = self.tracks.create()?; - tracing::debug!(name = ?track.name, ?config, "starting track"); - let mut published = config.clone(); - published.jitter = jitter; - catalog.video.renditions.insert(track.name.clone(), published); - self.config = Some(config); - self.track = - Some(crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy).with_lenient_start()); - - Ok(reconfigured) - } - - /// Initialize the decoder with SPS/PPS and other non-slice NALs. - pub fn initialize>(&mut self, buf: &mut T) -> anyhow::Result<()> { - let mut nals = NalIterator::new(buf); - - while let Some(nal) = nals.next().transpose()? { - self.decode_nal(nal, None)?; - } - - if let Some(nal) = nals.flush()? { - self.decode_nal(nal, None)?; - } - Ok(()) } - /// Returns a reference to the underlying track producer. - pub fn track(&self) -> anyhow::Result<&moq_net::TrackProducer> { - Ok(self.track.as_ref().context("not initialized")?.track()) - } - - /// Decode as much data as possible from the given buffer. - /// - /// Unlike [Self::decode_frame], this method needs the start code for the next frame. - /// This means it works for streaming media (ex. stdin) but adds a frame of latency. - /// - /// TODO: This currently associates PTS with the *previous* frame, as part of `maybe_start_frame`. - pub fn decode_stream>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - let pts = self.pts(pts)?; - - // Iterate over the NAL units in the buffer based on start codes. - let nals = NalIterator::new(buf); - - for nal in nals { - self.decode_nal(nal?, Some(pts))?; - } - - Ok(()) - } - - /// Decode all data in the buffer, assuming the buffer contains (the rest of) a frame. - /// - /// Unlike [Self::decode_stream], this is called when we know NAL boundaries. - /// This can avoid a frame of latency just waiting for the next frame's start code. - /// This can also be used when EOF is detected to flush the final frame. - /// - /// NOTE: The next decode will fail if it doesn't begin with a start code. - /// Record a frame's reorder delay (`PTS - DTS`) so the catalog `jitter` reflects the - /// B-frame reorder depth (the decode buffer a transmuxer/player must hold). The container - /// supplies this since the elementary stream alone carries no decode time. No-op until the - /// track exists. - pub fn observe_reorder(&mut self, reorder: crate::container::Timestamp) { - let Some(jitter) = self.jitter.observe_reorder(reorder) else { - return; - }; - let Some(track) = self.track.as_ref() else { - return; - }; - if let Some(c) = self.catalog.lock().video.renditions.get_mut(&track.name) { - c.jitter = Some(jitter); - } - } - - pub fn decode_frame>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - let pts = self.pts(pts)?; - // Iterate over the NAL units in the buffer based on start codes. - let mut nals = NalIterator::new(buf); - - // Iterate over each NAL that is followed by a start code. - while let Some(nal) = nals.next().transpose()? { - self.decode_nal(nal, Some(pts))?; - } - - // Assume the rest of the buffer is a single NAL. - if let Some(nal) = nals.flush()? { - self.decode_nal(nal, Some(pts))?; - } - - // Flush the frame if we read a slice. - self.maybe_start_frame(Some(pts))?; - - Ok(()) - } - - /// Decode a single NAL unit. Only reads the first header byte to extract nal_unit_type, - /// Ignores nuh_layer_id and nuh_temporal_id_plus1. - fn decode_nal(&mut self, nal: Bytes, pts: Option) -> anyhow::Result<()> { - anyhow::ensure!(nal.len() >= 2, "NAL unit is too short"); - // u16 header: [forbidden_zero_bit(1) | nal_unit_type(6) | nuh_layer_id(6) | nuh_temporal_id_plus1(3)] - let header = nal.first().context("NAL unit is too short")?; - - let forbidden_zero_bit = (header >> 7) & 1; - anyhow::ensure!(forbidden_zero_bit == 0, "forbidden zero bit is not zero"); - - // Bits 1-6: nal_unit_type - let nal_unit_type = (header >> 1) & 0b111111; - let nal_type = NALUnitType::from(nal_unit_type); - - match nal_type { - NALUnitType::VpsNut => { - self.maybe_start_frame(pts)?; - - // Track only what this AU carries; the retained set is reconciled at - // the keyframe so a new GOP's set replaces (not accumulates onto) it. - crate::codec::annexb::push_distinct(&mut self.current.vps_seen, &nal); + /// Write split frames to the track, resolving the config from the first + /// keyframe's inline SPS and refining the catalog jitter as it goes. + fn write_frames(&mut self, frames: impl IntoIterator) -> Result<()> { + for frame in frames { + if frame.keyframe + && let Some(sps) = find_sps(&frame.payload) + { + self.configure_from_sps(&sps)?; } - NALUnitType::SpsNut => { - self.maybe_start_frame(pts)?; - // Try to reinitialize the track if the SPS has changed. - let sps = SpsNALUnit::parse(&mut &nal[..]).context("failed to parse SPS NAL unit")?; - let reconfigured = self.init(&sps)?; - - // A changed config means the retained VPS/SPS/PPS no longer apply; they - // may already have been appended to current.chunks earlier in this AU, - // so reset the sets and AU so only the new parameter sets emit. - if reconfigured { - self.vps.clear(); - self.sps.clear(); - self.pps.clear(); - self.current.chunks.clear(); - // Keep vps_seen: in H.265 the VPS precedes the reconfiguring SPS, so - // any VPS already seen this AU belongs to the new config. Re-append - // it to the cleared chunks so the keyframe still carries it. - for nal in &self.current.vps_seen { - self.current.chunks.extend_from_slice(&START_CODE); - self.current.chunks.extend_from_slice(nal); - } - self.current.sps_seen.clear(); - self.current.pps_seen.clear(); - } - - crate::codec::annexb::push_distinct(&mut self.current.sps_seen, &nal); + // A keyframe we still can't configure (no SPS) is undecodable. + if frame.keyframe && self.config.is_none() { + return Err(Error::MissingSps.into()); } - NALUnitType::PpsNut => { - self.maybe_start_frame(pts)?; - crate::codec::annexb::push_distinct(&mut self.current.pps_seen, &nal); - } - NALUnitType::AudNut | NALUnitType::PrefixSeiNut | NALUnitType::SuffixSeiNut => { - self.maybe_start_frame(pts)?; - } - // Keyframe containing slices - NALUnitType::IdrWRadl - | NALUnitType::IdrNLp - | NALUnitType::BlaNLp - | NALUnitType::BlaWRadl - | NALUnitType::BlaWLp - | NALUnitType::CraNut => { - // Adopt this keyframe's inline set (dropping any the new GOP no longer - // uses), or re-inject the retained set if the keyframe carried none. - crate::codec::annexb::reconcile_keyframe_params( - &mut self.current.chunks, - &mut self.vps, - &mut self.current.vps_seen, - ); - crate::codec::annexb::reconcile_keyframe_params( - &mut self.current.chunks, - &mut self.sps, - &mut self.current.sps_seen, - ); - crate::codec::annexb::reconcile_keyframe_params( - &mut self.current.chunks, - &mut self.pps, - &mut self.current.pps_seen, - ); + let pts = frame.timestamp; + // A pre-keyframe delta has no group to anchor it: the producer returns + // MissingKeyframe, which the caller (e.g. a TS mid-stream join) skips. + self.track.write(frame)?; - self.current.contains_idr = true; - self.current.contains_slice = true; + if let Some(jitter) = self.jitter.observe(pts) { + self.rendition + .update(|c| c.jitter = moq_net::Time::try_from(jitter).ok()); } - // All other slice types (both N and R variants) - NALUnitType::TrailN - | NALUnitType::TrailR - | NALUnitType::TsaN - | NALUnitType::TsaR - | NALUnitType::StsaN - | NALUnitType::StsaR - | NALUnitType::RadlN - | NALUnitType::RadlR - | NALUnitType::RaslN - | NALUnitType::RaslR => { - // Check first_slice_segment_in_pic_flag (bit 7 of third byte, after 2-byte header) - if nal.get(2).context("NAL unit is too short")? & 0x80 != 0 { - self.maybe_start_frame(pts)?; - } - self.current.contains_slice = true; - } - _ => {} } - - // Replace the original start code with a canonical 4-byte start code (marginally easier - // for downstream players, e.g. MSE). - self.current.chunks.extend_from_slice(&START_CODE); - self.current.chunks.extend_from_slice(&nal); - Ok(()) } - fn maybe_start_frame(&mut self, pts: Option) -> anyhow::Result<()> { - // If we haven't seen any slices, we shouldn't flush yet. - if !self.current.contains_slice { - return Ok(()); - } - - let track = self.track.as_mut().context("expected SPS before any frames")?; - let pts = pts.context("missing timestamp")?; - - let payload = std::mem::take(&mut self.current.chunks).freeze(); - - let frame = crate::container::Frame { - timestamp: pts, - payload, - keyframe: self.current.contains_idr, - }; - - track.write(frame)?; - - if let Some(jitter) = self.jitter.observe(pts) - && let Some(c) = self.catalog.lock().video.renditions.get_mut(&track.name) - { - c.jitter = Some(jitter); - } - - self.current.contains_idr = false; - self.current.contains_slice = false; - self.current.vps_seen.clear(); - self.current.sps_seen.clear(); - self.current.pps_seen.clear(); - - Ok(()) - } - - /// Finish the track, flushing the current group. - pub fn finish(&mut self) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.finish()?; - Ok(()) - } - - /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.seek(sequence)?; - Ok(()) - } - - pub fn is_initialized(&self) -> bool { - self.track.is_some() + /// Publish split frames, resolving the config from the first keyframe's inline + /// SPS and refining the catalog jitter as it goes. + pub fn decode(&mut self, frames: impl IntoIterator) -> Result<()> { + self.write_frames(frames) } +} - fn pts(&mut self, hint: Option) -> anyhow::Result { - if let Some(pts) = hint { - return Ok(pts); - } - - let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); - Ok(crate::container::Timestamp::from_micros( - zero.elapsed().as_micros() as u64 - )?) - } +fn is_sps(nal: &[u8]) -> bool { + nal.first() + .is_some_and(|h| nal_unit_type(*h) == scuffle_h265::NALUnitType::SpsNut) } -impl Drop for Import { - fn drop(&mut self) { - if let Some(track) = &self.track { - tracing::debug!(name = ?track.name, "ending track"); - self.catalog.lock().video.renditions.remove(&track.name); +/// Find the first SPS NAL in an Annex-B payload, if any. +fn find_sps(payload: &[u8]) -> Option { + let mut buf = Bytes::copy_from_slice(payload); + let mut nals = NalIterator::new(&mut buf); + while let Some(Ok(nal)) = nals.next() { + if is_sps(&nal) { + return Some(nal); } } -} - -#[derive(Default)] -struct Frame { - chunks: BytesMut, - contains_idr: bool, - contains_slice: bool, - /// VPS/SPS/PPS NALs already inline in this access unit, so re-injection skips them. - vps_seen: Vec, - sps_seen: Vec, - pps_seen: Vec, + nals.flush().ok().flatten().filter(|nal| is_sps(nal)) } #[derive(Default)] diff --git a/rs/moq-mux/src/codec/h265/mod.rs b/rs/moq-mux/src/codec/h265/mod.rs index c3f471ae8..23c7a5836 100644 --- a/rs/moq-mux/src/codec/h265/mod.rs +++ b/rs/moq-mux/src/codec/h265/mod.rs @@ -3,16 +3,174 @@ //! The H.265 analogue of [`crate::codec::h264`]. Parses SPS NAL units //! and HEVCDecoderConfigurationRecord blobs. The [`Hvc1`] transmuxer //! rewrites Annex-B input (inline VPS/SPS/PPS) as length-prefixed NALU -//! + out-of-band hvcC. [`Import`] is the Annex-B importer. +//! + out-of-band hvcC. [`Export`] is the single-rendition Annex-B +//! exporter; [`Import`] is the Annex-B importer. +mod export; mod import; +mod split; +pub use export::*; pub use import::*; +pub use split::*; -use anyhow::Context; use bytes::{Buf, BufMut, Bytes, BytesMut}; use scuffle_h265::{NALUnitType, SpsNALUnit}; +/// H.265 parsing and transform errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("NAL unit is too short")] + NalTooShort, + + #[error("{0} too large for hvcC length field ({1} > {max})", max = u16::MAX)] + NalTooLargeForHvcc(&'static str, usize), + + #[error("too many {0} for hvcC ({1} > {max})", max = u16::MAX)] + TooManyNals(&'static str, usize), + + #[error("NAL too large for 4-byte length prefix")] + NalTooLarge, + + #[error("failed to parse SPS NAL unit")] + SpsParse, + + #[error("missing level_idc in SPS")] + MissingLevelIdc, + + #[error("forbidden zero bit is not zero")] + ForbiddenZeroBit, + + #[error("not initialized")] + NotInitialized, + + #[error("expected SPS before any frames")] + MissingSps, + + #[error("missing timestamp")] + MissingTimestamp, + + #[error("HEVCDecoderConfigurationRecord too short")] + HvccTooShort, + + #[error("HEVCDecoderConfigurationRecord truncated")] + HvccTruncated, + + #[error("hvc1 description for rendition {name:?} is missing VPS, SPS, or PPS (vps={vps}, sps={sps}, pps={pps})")] + MissingParamSets { + name: String, + vps: usize, + sps: usize, + pps: usize, + }, + + #[error("annexb: {0}")] + Annexb(#[from] crate::codec::annexb::Error), +} + +pub type Result = std::result::Result; + +/// The parameter sets carried out-of-band in an HEVCDecoderConfigurationRecord, +/// split by NAL type. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Hvcc { + /// NALU length size in bytes (typically 4). + pub length_size: usize, + /// VPS NAL units carried out-of-band in the record. + pub vps: Vec, + /// SPS NAL units carried out-of-band in the record. + pub sps: Vec, + /// PPS NAL units carried out-of-band in the record. + pub pps: Vec, +} + +impl Hvcc { + /// Parse an HEVCDecoderConfigurationRecord, sorting the VPS/SPS/PPS NAL units + /// by type. The HEVC analogue of [`super::h264::Avcc::parse`]. + pub fn parse(hvcc: &[u8]) -> Result { + if hvcc.len() < 23 { + return Err(Error::HvccTooShort); + } + let length_size = (hvcc[21] & 0x3) as usize + 1; + let num_arrays = hvcc[22] as usize; + + let mut vps = Vec::new(); + let mut sps = Vec::new(); + let mut pps = Vec::new(); + let mut pos: usize = 23; + + for _ in 0..num_arrays { + let after_hdr = pos.checked_add(3).ok_or(Error::HvccTruncated)?; + if hvcc.len() < after_hdr { + return Err(Error::HvccTruncated); + } + let nal_type = hvcc[pos] & 0x3f; + let num_nalus = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize; + pos = after_hdr; + + for _ in 0..num_nalus { + let after_len = pos.checked_add(2).ok_or(Error::HvccTruncated)?; + if hvcc.len() < after_len { + return Err(Error::HvccTruncated); + } + let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; + let after_nal = after_len.checked_add(len).ok_or(Error::HvccTruncated)?; + if hvcc.len() < after_nal { + return Err(Error::HvccTruncated); + } + let bytes = Bytes::copy_from_slice(&hvcc[after_len..after_nal]); + pos = after_nal; + + match NALUnitType::from(nal_type) { + NALUnitType::VpsNut => vps.push(bytes), + NALUnitType::SpsNut => sps.push(bytes), + NALUnitType::PpsNut => pps.push(bytes), + _ => {} + } + } + } + + Ok(Self { + length_size, + vps, + sps, + pps, + }) + } +} + +/// Build a catalog [`VideoConfig`](hang::catalog::VideoConfig) for the `hvc1` +/// shape from an HEVCDecoderConfigurationRecord (hvcC). +/// +/// The H.265 analogue of [`crate::codec::h264::Avcc::parse`] feeding a +/// `VideoConfig`. Used by the enhanced-RTMP / FLV importer, where the hvcC +/// arrives out of band in the sequence-header tag and the coded samples are +/// already length-prefixed NALU, so the record passes straight through as the +/// catalog `description` (`in_band: false`). +pub(crate) fn config_from_hvcc(hvcc: &[u8]) -> Result { + let params = Hvcc::parse(hvcc)?; + let sps_nal = params.sps.first().ok_or(Error::MissingSps)?; + let sps = SpsNALUnit::parse(&mut &sps_nal[..]).map_err(|_| Error::SpsParse)?; + let profile = &sps.rbsp.profile_tier_level.general_profile; + + let mut config = hang::catalog::VideoConfig::new(hang::catalog::H265 { + in_band: false, + profile_space: profile.profile_space, + profile_idc: profile.profile_idc, + profile_compatibility_flags: profile.profile_compatibility_flag.bits().to_be_bytes(), + tier_flag: profile.tier_flag, + level_idc: profile.level_idc.ok_or(Error::MissingLevelIdc)?, + constraint_flags: pack_constraint_flags(profile), + }); + config.coded_width = Some(sps.rbsp.cropped_width() as u32); + config.coded_height = Some(sps.rbsp.cropped_height() as u32); + config.description = Some(Bytes::copy_from_slice(hvcc)); + config.container = hang::catalog::Container::Legacy; + Ok(config) +} + /// Annex-B → length-prefixed transmuxer; the H.265 analogue of /// [`crate::codec::h264::Avc1`]. /// @@ -58,7 +216,7 @@ impl Hvc1 { /// - `Ok(None)` if the input contained only parameter sets and the /// transform is still waiting for slice NALs (hvcC may have been /// built as a side effect). - pub fn transform(&mut self, payload: Bytes) -> anyhow::Result> { + pub fn transform(&mut self, payload: Bytes) -> Result> { let mut buf = payload.clone(); let mut nal_iter = crate::codec::annexb::NalIterator::new(&mut buf); @@ -71,7 +229,7 @@ impl Hvc1 { loop { let nal = match nal_iter.next() { Some(Ok(n)) => n, - Some(Err(e)) => return Err(e), + Some(Err(e)) => return Err(e.into()), None => break, }; if process_nal(&nal, &mut out, &mut frame_vps, &mut frame_sps, &mut frame_pps)? { @@ -113,7 +271,7 @@ impl Hvc1 { Ok(Some(out.freeze())) } - fn rebuild_hvcc(&mut self) -> anyhow::Result<()> { + fn rebuild_hvcc(&mut self) -> Result<()> { if self.vps.is_empty() || self.sps.is_empty() || self.pps.is_empty() { return Ok(()); } @@ -131,7 +289,7 @@ fn process_nal( frame_vps: &mut Vec, frame_sps: &mut Vec, frame_pps: &mut Vec, -) -> anyhow::Result { +) -> Result { if nal.is_empty() { return Ok(false); } @@ -150,7 +308,7 @@ fn process_nal( Ok(false) } _ => { - let len = u32::try_from(nal.len()).context("NAL too large for 4-byte length prefix")?; + let len = u32::try_from(nal.len()).map_err(|_| Error::NalTooLarge)?; out.extend_from_slice(&len.to_be_bytes()); out.extend_from_slice(nal); Ok(true) @@ -158,89 +316,26 @@ fn process_nal( } } -/// Build a catalog [`VideoConfig`](hang::catalog::VideoConfig) for the `hvc1` -/// shape from an HEVCDecoderConfigurationRecord (hvcC). -/// -/// Used by the enhanced-RTMP / FLV importer: the hvcC arrives out of band in the -/// sequence-header tag and the coded samples are length-prefixed NALU, so the -/// record passes straight through as the catalog `description` (`in_band: false`). -pub(crate) fn config_from_hvcc(hvcc: &[u8]) -> anyhow::Result { - let sps_nal = hvcc_sps(hvcc)?; - let sps = SpsNALUnit::parse(&mut &sps_nal[..]).context("failed to parse SPS NAL unit for hvcC")?; - let profile = &sps.rbsp.profile_tier_level.general_profile; - - let mut config = hang::catalog::VideoConfig::new(hang::catalog::H265 { - in_band: false, - profile_space: profile.profile_space, - profile_idc: profile.profile_idc, - profile_compatibility_flags: profile.profile_compatibility_flag.bits().to_be_bytes(), - tier_flag: profile.tier_flag, - level_idc: profile.level_idc.context("missing level_idc in SPS")?, - constraint_flags: pack_constraint_flags(profile), - }); - config.coded_width = Some(sps.rbsp.cropped_width() as u32); - config.coded_height = Some(sps.rbsp.cropped_height() as u32); - config.description = Some(Bytes::copy_from_slice(hvcc)); - config.container = hang::catalog::Container::Legacy; - Ok(config) -} - -/// Extract the first SPS NAL from an HEVCDecoderConfigurationRecord, walking the -/// typed NAL arrays (unlike [`hvcc_params`], which flattens them). -fn hvcc_sps(hvcc: &[u8]) -> anyhow::Result { - anyhow::ensure!(hvcc.len() >= 23, "HEVCDecoderConfigurationRecord too short"); - let num_arrays = hvcc[22]; - let sps_nut = u8::from(NALUnitType::SpsNut); - - let mut pos = 23; - for _ in 0..num_arrays { - anyhow::ensure!(hvcc.len() >= pos + 3, "truncated hvcC NAL array header"); - let nal_type = hvcc[pos] & 0x3f; - pos += 1; - let num_nalus = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]); - pos += 2; - for i in 0..num_nalus { - anyhow::ensure!(hvcc.len() >= pos + 2, "truncated hvcC NAL length"); - let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; - pos += 2; - anyhow::ensure!(hvcc.len() >= pos + len, "hvcC NAL exceeds buffer"); - if nal_type == sps_nut && i == 0 { - return Ok(Bytes::copy_from_slice(&hvcc[pos..pos + len])); - } - pos += len; - } - } - - anyhow::bail!("hvcC has no SPS") -} - /// Build an HEVCDecoderConfigurationRecord (ISO/IEC 14496-15 §8.3.3). /// Single-layer streams only. Each NAL array (VPS, SPS, PPS) carries every /// distinct parameter set the stream defined, in arrival order; the profile/tier /// fields are read from the first SPS. -pub(crate) fn build_hvcc(vps_nals: &[Bytes], sps_nals: &[Bytes], pps_nals: &[Bytes]) -> anyhow::Result { - let first_sps = sps_nals.first().context("hvcC requires at least one SPS")?; +pub(crate) fn build_hvcc(vps_nals: &[Bytes], sps_nals: &[Bytes], pps_nals: &[Bytes]) -> Result { + let first_sps = sps_nals.first().ok_or(Error::MissingSps)?; for (label, nals) in [("VPS", vps_nals), ("SPS", sps_nals), ("PPS", pps_nals)] { - anyhow::ensure!( - nals.len() <= u16::MAX as usize, - "too many {} for hvcC ({} > 65535)", - label, - nals.len() - ); + if nals.len() > u16::MAX as usize { + return Err(Error::TooManyNals(label, nals.len())); + } for nal in nals { - anyhow::ensure!( - nal.len() <= u16::MAX as usize, - "{} too large for hvcC length field ({} > {})", - label, - nal.len(), - u16::MAX - ); + if nal.len() > u16::MAX as usize { + return Err(Error::NalTooLargeForHvcc(label, nal.len())); + } } } - let sps = SpsNALUnit::parse(&mut &first_sps[..]).context("failed to parse SPS NAL unit for hvcC")?; + let sps = SpsNALUnit::parse(&mut &first_sps[..]).map_err(|_| Error::SpsParse)?; let profile = &sps.rbsp.profile_tier_level.general_profile; - let level_idc = profile.level_idc.context("missing level_idc in SPS")?; + let level_idc = profile.level_idc.ok_or(Error::MissingLevelIdc)?; let constraint_flags = pack_constraint_flags(profile); let compat = profile.profile_compatibility_flag.bits().to_be_bytes(); let num_temporal_layers = sps.rbsp.sps_max_sub_layers_minus1 + 1; @@ -312,60 +407,6 @@ pub(crate) fn hvcc_params(hvcc: &[u8]) -> anyhow::Result<(usize, Vec)> { Ok((length_size, params)) } -/// The parameter sets carried out-of-band in an HEVCDecoderConfigurationRecord, -/// split by NAL type. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct Hvcc { - /// NALU length size in bytes (typically 4). - pub length_size: usize, - pub vps: Vec, - pub sps: Vec, - pub pps: Vec, -} - -impl Hvcc { - /// Parse an HEVCDecoderConfigurationRecord, sorting the VPS/SPS/PPS NAL units - /// by type. The HEVC analogue of [`super::h264::Avcc::parse`]: used to recover - /// the Annex-B parameter sets a length-prefixed (hvc1) stream needs at each - /// keyframe for in-band injection (VPS→SPS→PPS order). - pub fn parse(hvcc: &[u8]) -> anyhow::Result { - anyhow::ensure!(hvcc.len() >= 23, "HEVCDecoderConfigurationRecord too short"); - let length_size = (hvcc[21] & 0x03) as usize + 1; - let num_arrays = hvcc[22]; - - let mut out = Self { - length_size, - vps: Vec::new(), - sps: Vec::new(), - pps: Vec::new(), - }; - let mut pos = 23; - for _ in 0..num_arrays { - anyhow::ensure!(hvcc.len() >= pos + 3, "truncated hvcC NAL array header"); - let nal_type = hvcc[pos] & 0x3f; - let num_nalus = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]); - pos += 3; - for _ in 0..num_nalus { - anyhow::ensure!(hvcc.len() >= pos + 2, "truncated hvcC NAL length"); - let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; - pos += 2; - anyhow::ensure!(hvcc.len() >= pos + len, "hvcC NAL exceeds buffer"); - let nal = Bytes::copy_from_slice(&hvcc[pos..pos + len]); - pos += len; - match NALUnitType::from(nal_type) { - NALUnitType::VpsNut => out.vps.push(nal), - NALUnitType::SpsNut => out.sps.push(nal), - NALUnitType::PpsNut => out.pps.push(nal), - _ => {} - } - } - } - - Ok(out) - } -} - /// Pack the constraint flags from ITU H.265 V10 §7.3.3 Profile, tier and level syntax. pub(crate) fn pack_constraint_flags(profile: &scuffle_h265::Profile) -> [u8; 6] { let mut flags = [0u8; 6]; @@ -410,12 +451,5 @@ mod tests { assert_eq!(params[0].as_ref(), vps); assert_eq!(params[1].as_ref(), sps); assert_eq!(params[2].as_ref(), pps); - - // The typed parser keys the same NALs by type. - let parsed = Hvcc::parse(&hvcc).unwrap(); - assert_eq!(parsed.length_size, 4); - assert_eq!(parsed.vps, vec![Bytes::copy_from_slice(vps)]); - assert_eq!(parsed.sps, vec![Bytes::copy_from_slice(sps)]); - assert_eq!(parsed.pps, vec![Bytes::copy_from_slice(pps)]); } } diff --git a/rs/moq-mux/src/codec/h265/split.rs b/rs/moq-mux/src/codec/h265/split.rs new file mode 100644 index 000000000..185cc2748 --- /dev/null +++ b/rs/moq-mux/src/codec/h265/split.rs @@ -0,0 +1,365 @@ +//! H.265 Annex-B stream splitter. +//! +//! The H.265 analogue of [`crate::codec::h264::Split`]: turns a raw Annex-B byte +//! stream (inline VPS/SPS/PPS) into [`crate::container::Frame`]s. It finds +//! access-unit boundaries, caches VPS/SPS/PPS and re-inserts them ahead of each +//! keyframe so every keyframe is self-contained, and stamps wall-clock +//! timestamps when the caller has none (stdin). It owns no track, catalog, or +//! codec config. The importer parses the codec config out of the frames it +//! emits. + +use bytes::{Bytes, BytesMut}; +use scuffle_h265::NALUnitType; + +use super::Error; +use crate::Result; +use crate::codec::annexb::{NalIterator, START_CODE}; + +/// H.265 Annex-B stream splitter: bytes in, [`Frame`](crate::container::Frame)s out. +/// +/// Feed bytes via [`decode`](Self::decode) (unknown frame boundaries, e.g. +/// stdin); call [`flush`](Self::flush) to emit the final in-flight access unit. +/// VPS/SPS/PPS seen inline are cached and re-inserted ahead of each keyframe so +/// each keyframe is self-contained. +pub struct Split { + /// Bytes carried over between calls: complete NALs are parsed out on each + /// [`decode`](Self::decode), leaving the in-flight (final, not-yet-terminated) + /// NAL here until the next start code arrives or [`flush`](Self::flush) drains it. + tail: BytesMut, + current: Au, + /// Retained VPS NALs from the latest keyframe that carried them, re-injected + /// on bare keyframes. Replaced (not accumulated) when a keyframe presents a + /// different set, so a mid-stream reinit drops the superseded ones. + vps: Vec, + /// Retained SPS NALs. See [`vps`](Self::vps). + sps: Vec, + /// Retained PPS NALs. A keyframe may carry several (slices reference them by + /// id); all are kept and re-injected, but a new GOP's set supersedes them. + pps: Vec, + zero: Option, + pending: Vec, +} + +#[derive(Default)] +struct Au { + chunks: BytesMut, + contains_idr: bool, + contains_slice: bool, + /// VPS NALs already inline in this access unit, so re-injection skips them. + vps_seen: Vec, + /// SPS NALs already inline in this access unit. + sps_seen: Vec, + /// PPS NALs already inline in this access unit. + pps_seen: Vec, +} + +impl Default for Split { + fn default() -> Self { + Self::new() + } +} + +impl Split { + /// A fresh splitter with an empty parameter-set cache. + pub fn new() -> Self { + Self { + tail: BytesMut::new(), + current: Au::default(), + vps: Vec::new(), + sps: Vec::new(), + pps: Vec::new(), + zero: None, + pending: Vec::new(), + } + } + + /// Decode a buffer where frame boundaries are unknown, returning the access + /// units it can complete. The leading start code of the *next* access unit is + /// what signals the previous one is complete, so the final NAL of the in-flight + /// access unit stays buffered until the next call (or [`flush`](Self::flush)). + /// The buffer is fully consumed. + pub fn decode( + &mut self, + data: &[u8], + pts: impl Into>, + ) -> Result> { + let pts = self.pts(pts.into())?; + self.tail.extend_from_slice(data); + // Iterate complete NALs out of `tail`, leaving the trailing (in-flight) NAL + // (with its start code) buffered for the next call or `flush`. + let nals = NalIterator::new(&mut self.tail); + let mut parsed = Vec::new(); + for nal in nals { + parsed.push(nal?); + } + for nal in parsed { + self.decode_nal(nal, pts)?; + } + Ok(std::mem::take(&mut self.pending)) + } + + /// Emit the in-flight access unit, if any. Call after the last + /// [`decode`](Self::decode) when a caller handed over a complete access unit + /// (or at end of stream) so the final NAL isn't left buffered. + pub fn flush( + &mut self, + pts: impl Into>, + ) -> Result> { + let pts = self.pts(pts.into())?; + if let Some(nal) = NalIterator::new(&mut self.tail).flush()? { + self.decode_nal(nal, pts)?; + } + self.tail.clear(); + self.maybe_start_frame(pts)?; + Ok(std::mem::take(&mut self.pending)) + } + + /// Decode a single NAL unit. Only reads the first header byte to extract + /// nal_unit_type, ignoring nuh_layer_id and nuh_temporal_id_plus1. + fn decode_nal(&mut self, nal: Bytes, pts: crate::container::Timestamp) -> Result<()> { + if nal.len() < 2 { + return Err(Error::NalTooShort.into()); + } + // u16 header: [forbidden_zero_bit(1) | nal_unit_type(6) | nuh_layer_id(6) | nuh_temporal_id_plus1(3)] + let header = nal.first().ok_or(Error::NalTooShort)?; + if (header >> 7) & 1 != 0 { + return Err(Error::ForbiddenZeroBit.into()); + } + + let nal_type = nal_unit_type(*header); + + match nal_type { + NALUnitType::VpsNut => { + self.maybe_start_frame(pts)?; + crate::codec::annexb::push_distinct(&mut self.current.vps_seen, &nal); + } + NALUnitType::SpsNut => { + self.maybe_start_frame(pts)?; + crate::codec::annexb::push_distinct(&mut self.current.sps_seen, &nal); + } + NALUnitType::PpsNut => { + self.maybe_start_frame(pts)?; + crate::codec::annexb::push_distinct(&mut self.current.pps_seen, &nal); + } + NALUnitType::AudNut | NALUnitType::PrefixSeiNut | NALUnitType::SuffixSeiNut => { + self.maybe_start_frame(pts)?; + } + // Keyframe containing slices. + NALUnitType::IdrWRadl + | NALUnitType::IdrNLp + | NALUnitType::BlaNLp + | NALUnitType::BlaWRadl + | NALUnitType::BlaWLp + | NALUnitType::CraNut => { + // Adopt this keyframe's inline set (dropping any the new GOP no longer + // uses), or re-inject the retained set if the keyframe carried none. + crate::codec::annexb::reconcile_keyframe_params( + &mut self.current.chunks, + &mut self.vps, + &mut self.current.vps_seen, + ); + crate::codec::annexb::reconcile_keyframe_params( + &mut self.current.chunks, + &mut self.sps, + &mut self.current.sps_seen, + ); + crate::codec::annexb::reconcile_keyframe_params( + &mut self.current.chunks, + &mut self.pps, + &mut self.current.pps_seen, + ); + + self.current.contains_idr = true; + self.current.contains_slice = true; + } + // All other slice types (both N and R variants). + NALUnitType::TrailN + | NALUnitType::TrailR + | NALUnitType::TsaN + | NALUnitType::TsaR + | NALUnitType::StsaN + | NALUnitType::StsaR + | NALUnitType::RadlN + | NALUnitType::RadlR + | NALUnitType::RaslN + | NALUnitType::RaslR => { + // Check first_slice_segment_in_pic_flag (bit 7 of third byte, after 2-byte header). + if nal.get(2).ok_or(Error::NalTooShort)? & 0x80 != 0 { + self.maybe_start_frame(pts)?; + } + self.current.contains_slice = true; + } + _ => {} + } + + // Replace the original start code with a canonical 4-byte start code (marginally + // easier for downstream players, e.g. MSE). + self.current.chunks.extend_from_slice(&START_CODE); + self.current.chunks.extend_from_slice(&nal); + + Ok(()) + } + + fn maybe_start_frame(&mut self, pts: crate::container::Timestamp) -> Result<()> { + if !self.current.contains_slice { + return Ok(()); + } + + let payload = std::mem::take(&mut self.current.chunks).freeze(); + let keyframe = self.current.contains_idr; + self.current.contains_idr = false; + self.current.contains_slice = false; + self.current.vps_seen.clear(); + self.current.sps_seen.clear(); + self.current.pps_seen.clear(); + + self.pending.push(crate::container::Frame { + timestamp: pts, + payload, + keyframe, + duration: None, + }); + Ok(()) + } + + /// Drop any in-flight access unit. + /// + /// Pre-reset NALs would otherwise leak into a later frame with the wrong + /// timestamp. The parameter-set cache is kept so subsequent keyframes stay + /// self-contained. + pub fn reset(&mut self) { + self.current = Au::default(); + self.tail.clear(); + } + + fn pts(&mut self, hint: Option) -> Result { + if let Some(pts) = hint { + return Ok(pts); + } + let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); + Ok(crate::container::Timestamp::from_micros( + zero.elapsed().as_micros() as u64 + )?) + } +} + +/// Extract the HEVC `nal_unit_type` from the first header byte (bits 1..=6). +pub(super) fn nal_unit_type(header: u8) -> NALUnitType { + NALUnitType::from((header >> 1) & 0b111111) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SC4: &[u8] = &[0, 0, 0, 1]; + + // HEVC NAL headers: byte0 = nal_unit_type << 1 (forbidden bit 0, layer id 0). + const VPS: &[u8] = &[0x40, 0x01, 0x0c]; // type 32 + const SPS: &[u8] = &[0x42, 0x01, 0x01]; // type 33 + const PPS: &[u8] = &[0x44, 0x01, 0xc0]; // type 34 + const IDR: &[u8] = &[0x26, 0x01, 0x80, 0xaa]; // type 19 (IdrWRadl) + + fn annexb(nals: &[&[u8]]) -> BytesMut { + let mut buf = BytesMut::new(); + for nal in nals { + buf.extend_from_slice(SC4); + buf.extend_from_slice(nal); + } + buf + } + + fn ts() -> crate::container::Timestamp { + crate::container::Timestamp::from_micros(0).unwrap() + } + + fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) + } + + /// Decode one complete access unit handed over as a single buffer: `decode` + /// buffers it, `flush` emits it. + fn decode_one( + split: &mut Split, + buf: &mut BytesMut, + pts: crate::container::Timestamp, + ) -> Vec { + let mut frames = split.decode(buf, pts).unwrap(); + frames.extend(split.flush(pts).unwrap()); + frames + } + + /// A keyframe access unit fed as one buffer emits one self-contained frame: + /// VPS+SPS+PPS are packaged ahead of the IDR slice and `keyframe` is set. + #[tokio::test(start_paused = true)] + async fn decode_packages_keyframe() { + let mut split = Split::new(); + let frames = decode_one(&mut split, &mut annexb(&[VPS, SPS, PPS, IDR]), ts()); + + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe); + assert!(contains(&frames[0].payload, VPS)); + assert!(contains(&frames[0].payload, SPS)); + assert!(contains(&frames[0].payload, PPS)); + assert!(contains(&frames[0].payload, IDR)); + } + + /// Parameter sets fed up front (as the leading stream bytes) are cached and + /// re-inserted ahead of a later bare IDR, so the keyframe is self-contained + /// even when the stream never repeats its parameter sets inline. + #[tokio::test(start_paused = true)] + async fn params_then_bare_keyframe_self_contained() { + let mut split = Split::new(); + // The leading VPS/SPS/PPS carry no slice, so they complete no frame yet. + assert!(split.decode(&annexb(&[VPS, SPS, PPS]), ts()).unwrap().is_empty()); + + let frames = decode_one(&mut split, &mut annexb(&[IDR]), ts()); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe); + assert!(contains(&frames[0].payload, VPS)); + assert!(contains(&frames[0].payload, SPS)); + assert!(contains(&frames[0].payload, PPS)); + } + + /// A source that defines two PPS (and is otherwise normal) once, then sends a + /// bare IDR: both cached PPS must be re-injected on the keyframe, not just the + /// last one. Regression for the multi-PPS collapse. + #[tokio::test(start_paused = true)] + async fn reinjects_all_cached_pps_on_keyframe() { + const PPS1: &[u8] = &[0x44, 0x01, 0xc1]; // second PPS, type 34 + + let mut split = Split::new(); + let first = decode_one(&mut split, &mut annexb(&[VPS, SPS, PPS, PPS1, IDR]), ts()); + assert_eq!(first.len(), 1); + assert!(first[0].keyframe); + + // Bare IDR: the splitter re-injects VPS + SPS + both PPS in order. + let second = decode_one(&mut split, &mut annexb(&[IDR]), ts()); + assert_eq!(second.len(), 1); + assert!(second[0].keyframe); + assert_eq!( + second[0].payload.as_ref(), + annexb(&[VPS, SPS, PPS, PPS1, IDR]).freeze().as_ref() + ); + } + + /// A keyframe that presents a smaller parameter set than a prior one reinits + /// the retained set: the dropped PPS must not be re-injected on later bare + /// keyframes. + #[tokio::test(start_paused = true)] + async fn reinit_drops_superseded_pps_on_keyframe() { + const PPS1: &[u8] = &[0x44, 0x01, 0xc1]; + + let mut split = Split::new(); + let _ = decode_one(&mut split, &mut annexb(&[VPS, SPS, PPS, PPS1, IDR]), ts()); + let _ = decode_one(&mut split, &mut annexb(&[VPS, SPS, PPS, IDR]), ts()); + let third = decode_one(&mut split, &mut annexb(&[IDR]), ts()); + + assert_eq!(third.len(), 1); + assert!(third[0].keyframe); + assert_eq!( + third[0].payload.as_ref(), + annexb(&[VPS, SPS, PPS, IDR]).freeze().as_ref() + ); + } +} diff --git a/rs/moq-mux/src/codec/legacy.rs b/rs/moq-mux/src/codec/legacy.rs index b346ca032..ba01a23b6 100644 --- a/rs/moq-mux/src/codec/legacy.rs +++ b/rs/moq-mux/src/codec/legacy.rs @@ -7,9 +7,74 @@ //! codec contributes only a header parser and a [`Descriptor`]; this module //! owns the track lifecycle. -use bytes::{Buf, BytesMut}; - use crate::catalog::hang::CatalogExt; +use crate::container::Frame; +use crate::container::Timestamp; + +/// Legacy audio (MP2 / AC-3 / E-AC-3) header parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("AC-3 header needs 7 bytes")] + Ac3HeaderTooShort, + + #[error("missing AC-3 sync word")] + Ac3MissingSyncWord, + + #[error("invalid AC-3 frame size code")] + Ac3InvalidFrameSizeCode, + + #[error("unsupported AC-3 bsid {0}")] + Ac3UnsupportedBsid(u8), + + #[error("reserved AC-3 sample-rate code")] + Ac3ReservedSampleRate, + + #[error("E-AC-3 header needs 6 bytes")] + Eac3HeaderTooShort, + + #[error("missing E-AC-3 sync word")] + Eac3MissingSyncWord, + + #[error("not an E-AC-3 bitstream (bsid {0})")] + Eac3NotEac3Bsid(u8), + + #[error("reserved E-AC-3 stream type")] + Eac3ReservedStreamType, + + #[error("E-AC-3 dependent substream (7.1+ layout) is not supported; only a single independent substream")] + Eac3DependentSubstream, + + #[error("E-AC-3 additional substream {0} is not supported; only a single independent substream")] + Eac3AdditionalSubstream(u8), + + #[error("E-AC-3 frame length {0} shorter than its header")] + Eac3FrameShorterThanHeader(usize), + + #[error("reserved E-AC-3 sample-rate code")] + Eac3ReservedSampleRate, + + #[error("MP2 header needs 4 bytes")] + Mp2HeaderTooShort, + + #[error("missing MP2 frame sync")] + Mp2MissingSync, + + #[error("reserved or MPEG-2.5 audio version")] + Mp2ReservedVersion, + + #[error("not MPEG Layer II")] + Mp2NotLayerII, + + #[error("reserved MP2 sample-rate index")] + Mp2ReservedSampleRate, + + #[error("free-format or invalid MP2 bitrate")] + Mp2InvalidBitrate, +} + +/// A Result type alias for legacy audio header parsing. +pub type Result = std::result::Result; /// A parsed legacy-audio frame header. #[derive(Debug)] @@ -32,7 +97,7 @@ pub(crate) struct Descriptor { /// Bytes needed to attempt a header parse. pub min_header_len: usize, /// Parse one frame header at the start of the slice. - pub parse: fn(&[u8]) -> anyhow::Result
, + pub parse: fn(&[u8]) -> Result
, } /// Catalog config for a legacy audio track. Both fields come from the frame @@ -48,20 +113,20 @@ pub(crate) struct Config { /// forwards it immediately. The audio is never decoded; the catalog carries the /// codec, sample rate and channel count read from the frame header. pub(crate) struct Import { - catalog: crate::catalog::Producer, track: crate::container::Producer, - zero: Option, + rendition: crate::catalog::AudioTrack, } impl Import { + /// Publish on an existing track, registering the rendition in `catalog`. Mint the + /// track at the descriptor's suffix (e.g. via [`crate::import::unique_track`]); frames are + /// stamped at the microsecond timescale. pub fn new( descriptor: &'static Descriptor, - mut broadcast: moq_net::BroadcastProducer, - mut catalog: crate::catalog::Producer, + track: moq_net::TrackProducer, + catalog: crate::catalog::Producer, config: Config, - ) -> anyhow::Result { - let track = broadcast.unique_track(descriptor.track_suffix)?; - + ) -> Self { let mut audio_config = hang::catalog::AudioConfig::new(descriptor.codec.clone(), config.sample_rate, config.channel_count); audio_config.container = hang::catalog::Container::Legacy; @@ -69,72 +134,44 @@ impl Import { // consumer needs out-of-band config (TS export self-describes; WebCodecs // cannot decode these codecs). Fill it only if a real consumer ever needs it. - tracing::debug!(name = ?track.name, config = ?audio_config, "starting track"); - catalog.lock().audio.renditions.insert(track.name.clone(), audio_config); + tracing::debug!(name = ?track.name(), config = ?audio_config, "starting track"); - Ok(Self { - catalog, + let mut rendition = catalog.audio_track(track.name()); + rendition.set(audio_config); + + Self { track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), - zero: None, - }) + rendition, + } } /// The MoQ track name. pub fn name(&self) -> &str { - &self.track.name + self.track.name() } /// Finish the track, flushing the current group. - pub fn finish(&mut self) -> anyhow::Result<()> { + pub fn finish(&mut self) -> crate::Result<()> { self.track.finish()?; Ok(()) } /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { + pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; Ok(()) } /// Publish one whole frame as a hang frame in its own group. - pub fn decode(&mut self, buf: &mut T, pts: Option) -> anyhow::Result<()> { - let pts = self.pts(pts)?; - - let mut payload = BytesMut::with_capacity(buf.remaining()); - while buf.has_remaining() { - let chunk = buf.chunk(); - payload.extend_from_slice(chunk); - let len = chunk.len(); - buf.advance(len); - } - - let frame = crate::container::Frame { - timestamp: pts, - payload: payload.freeze(), + pub fn decode(&mut self, frame: &[u8], pts: Option) -> crate::Result<()> { + let timestamp = self.rendition.timestamp(pts)?; + self.track.write(Frame { + timestamp, + duration: None, + payload: bytes::Bytes::copy_from_slice(frame), keyframe: true, - }; - - self.track.write(frame)?; + })?; self.track.finish_group()?; - Ok(()) } - - fn pts(&mut self, hint: Option) -> anyhow::Result { - if let Some(pts) = hint { - return Ok(pts); - } - - let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); - Ok(crate::container::Timestamp::from_micros( - zero.elapsed().as_micros() as u64 - )?) - } -} - -impl Drop for Import { - fn drop(&mut self) { - tracing::debug!(name = ?self.track.name, "ending track"); - self.catalog.lock().audio.renditions.remove(&self.track.name); - } } diff --git a/rs/moq-mux/src/codec/mp2.rs b/rs/moq-mux/src/codec/mp2.rs index cd8690baa..0b5610402 100644 --- a/rs/moq-mux/src/codec/mp2.rs +++ b/rs/moq-mux/src/codec/mp2.rs @@ -32,10 +32,14 @@ const SAMPLE_RATE: [[u32; 3]; 2] = [[44100, 48000, 32000], [22050, 24000, 16000] const SAMPLES_PER_FRAME: u64 = 1152; /// Parse a Layer II frame header from the start of `data` (needs >= 4 bytes). -pub(crate) fn parse_header(data: &[u8]) -> anyhow::Result { - anyhow::ensure!(data.len() >= 4, "MP2 header needs 4 bytes"); +pub(crate) fn parse_header(data: &[u8]) -> legacy::Result { + if data.len() < 4 { + return Err(legacy::Error::Mp2HeaderTooShort); + } // Frame sync: 11 bits set (0xFFE). - anyhow::ensure!(data[0] == 0xFF && (data[1] & 0xE0) == 0xE0, "missing MP2 frame sync"); + if !(data[0] == 0xFF && (data[1] & 0xE0) == 0xE0) { + return Err(legacy::Error::Mp2MissingSync); + } // 0b00 is the unofficial MPEG-2.5 extension: 13818-1 has no stream type for // it, so accepting it here would re-announce it as 0x04 on export and invent @@ -44,23 +48,29 @@ pub(crate) fn parse_header(data: &[u8]) -> anyhow::Result { let (version, sr_row) = match (data[1] >> 3) & 0x03 { 0b11 => (Version::Mpeg1, 0), 0b10 => (Version::Mpeg2, 1), - _ => anyhow::bail!("reserved or MPEG-2.5 audio version"), + _ => return Err(legacy::Error::Mp2ReservedVersion), }; // Layer field 0b10 is Layer II. - anyhow::ensure!((data[1] >> 1) & 0x03 == 0b10, "not MPEG Layer II"); + if (data[1] >> 1) & 0x03 != 0b10 { + return Err(legacy::Error::Mp2NotLayerII); + } let bitrate_index = (data[2] >> 4) & 0x0F; let sr_index = (data[2] >> 2) & 0x03; let padding = ((data[2] >> 1) & 0x01) as usize; - anyhow::ensure!(sr_index != 3, "reserved MP2 sample-rate index"); + if sr_index == 3 { + return Err(legacy::Error::Mp2ReservedSampleRate); + } let sample_rate = SAMPLE_RATE[sr_row][sr_index as usize]; let bitrate_kbps = match version { Version::Mpeg1 => BITRATE_MPEG1_L2[bitrate_index as usize], Version::Mpeg2 => BITRATE_MPEG2_L2[bitrate_index as usize], }; - anyhow::ensure!(bitrate_kbps != 0, "free-format or invalid MP2 bitrate"); + if bitrate_kbps == 0 { + return Err(legacy::Error::Mp2InvalidBitrate); + } // Layer II is always 1152 samples, so the frame is 144 * bitrate / sample_rate bytes. let len = (144 * bitrate_kbps * 1000 / sample_rate) as usize + padding; diff --git a/rs/moq-mux/src/codec/opus/import.rs b/rs/moq-mux/src/codec/opus/import.rs index a1357401d..16d77af5f 100644 --- a/rs/moq-mux/src/codec/opus/import.rs +++ b/rs/moq-mux/src/codec/opus/import.rs @@ -1,123 +1,73 @@ -use bytes::{Buf, BytesMut}; - use super::Config; +use crate::catalog::hang::CatalogExt; +use crate::container::Frame; /// Opus importer. /// -/// Initialized from an OpusHead packet. Each input buffer passed to [`decode`](Self::decode) -/// is published as one hang frame in its own group, so the relay can forward each frame -/// without waiting for a group boundary. Opus' packet loss concealment handles drops. -/// Ogg framing is not supported, feed raw Opus packets. -pub struct Import { - catalog: crate::catalog::Producer, +/// Publishes raw Opus frames (no Ogg framing) to a single moq track. Build it with +/// [`new`](Self::new), passing the track producer and the +/// [`catalog::Producer`](crate::catalog::Producer) it publishes its rendition into. +/// +/// Each packet handed to [`decode`](Self::decode) is published in its own group so +/// the relay can forward it immediately without waiting for a group boundary; Opus' +/// packet loss concealment handles drops. +pub struct Import { track: crate::container::Producer, - zero: Option, + rendition: crate::catalog::AudioTrack, } -impl Import { +impl Import { + /// Publish on an existing track producer, registering the rendition in `catalog`. pub fn new( - broadcast: moq_net::BroadcastProducer, - catalog: crate::catalog::Producer, - config: Config, - ) -> anyhow::Result { - Self::new_with_source( - crate::track_provider::TrackProvider::unique(broadcast, ".opus"), - catalog, - config, - ) - } - - pub fn new_with_track( track: moq_net::TrackProducer, - catalog: crate::catalog::Producer, + catalog: crate::catalog::Producer, config: Config, - ) -> anyhow::Result { - Self::new_with_source(crate::track_provider::TrackProvider::fixed(track), catalog, config) - } - - fn new_with_source( - mut tracks: crate::track_provider::TrackProvider, - mut catalog: crate::catalog::Producer, - config: Config, - ) -> anyhow::Result { - let track = tracks.create()?; - - let mut audio_config = hang::catalog::AudioConfig::new( + ) -> crate::Result { + let mut audio = hang::catalog::AudioConfig::new( hang::catalog::AudioCodec::Opus, config.sample_rate, config.channel_count, ); - audio_config.container = hang::catalog::Container::Legacy; + audio.container = hang::catalog::Container::Legacy; + + tracing::debug!(name = ?track.name(), config = ?audio, "starting track"); - tracing::debug!(name = ?track.name, config = ?audio_config, "starting track"); - catalog.lock().audio.renditions.insert(track.name.clone(), audio_config); + let mut rendition = catalog.audio_track(track.name()); + rendition.set(audio); Ok(Self { - catalog, track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), - zero: None, + rendition, }) } - /// Returns a reference to the underlying track producer, e.g. for - /// monitoring subscriber state via `used()`/`unused()`. - pub fn track(&self) -> &moq_net::TrackProducer { - self.track.track() + /// A watch-only handle to this track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + self.track.track().demand() } /// Finish the track, flushing the current group. - pub fn finish(&mut self) -> anyhow::Result<()> { + pub fn finish(&mut self) -> crate::Result<()> { self.track.finish()?; Ok(()) } /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { + pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; Ok(()) } - pub fn decode(&mut self, buf: &mut T, pts: Option) -> anyhow::Result<()> { - let pts = self.pts(pts)?; - - // Collect the input into a contiguous Bytes payload. - let mut payload = BytesMut::with_capacity(buf.remaining()); - while buf.has_remaining() { - let chunk = buf.chunk(); - payload.extend_from_slice(chunk); - let len = chunk.len(); - buf.advance(len); - } - - // Each frame is its own group so the relay can forward it immediately. - // Opus' packet loss concealment handles drops. - let frame = crate::container::Frame { - timestamp: pts, - payload: payload.freeze(), + /// Publish one Opus packet as its own group, stamping `pts` or a wall clock when absent. + pub fn decode(&mut self, frame: &[u8], pts: Option) -> crate::Result<()> { + let timestamp = self.rendition.timestamp(pts)?; + self.track.write(Frame { + timestamp, + payload: bytes::Bytes::copy_from_slice(frame), keyframe: true, - }; - - self.track.write(frame)?; + duration: None, + })?; self.track.finish_group()?; - Ok(()) } - - fn pts(&mut self, hint: Option) -> anyhow::Result { - if let Some(pts) = hint { - return Ok(pts); - } - - let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); - Ok(crate::container::Timestamp::from_micros( - zero.elapsed().as_micros() as u64 - )?) - } -} - -impl Drop for Import { - fn drop(&mut self) { - tracing::debug!(name = ?self.track.name, "ending track"); - self.catalog.lock().audio.renditions.remove(&self.track.name); - } } diff --git a/rs/moq-mux/src/codec/opus/mod.rs b/rs/moq-mux/src/codec/opus/mod.rs index 7416ca958..035a397a0 100644 --- a/rs/moq-mux/src/codec/opus/mod.rs +++ b/rs/moq-mux/src/codec/opus/mod.rs @@ -11,6 +11,19 @@ use bytes::{Buf, Bytes}; const OPUS_HEAD: u64 = u64::from_be_bytes(*b"OpusHead"); +/// Opus parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("OpusHead must be at least 19 bytes")] + HeadTooShort, + + #[error("invalid OpusHead signature")] + InvalidSignature, +} + +pub type Result = std::result::Result; + /// Typed Opus configuration mirroring the parsed fields of an OpusHead packet. pub struct Config { pub sample_rate: u32, @@ -23,10 +36,14 @@ impl Config { /// Verifies the magic signature; reads channel count and sample rate; /// ignores pre-skip, gain, and channel mapping. Any trailing bytes are /// consumed. - pub fn parse(buf: &mut T) -> anyhow::Result { - anyhow::ensure!(buf.remaining() >= 19, "OpusHead must be at least 19 bytes"); + pub fn parse(buf: &mut T) -> Result { + if buf.remaining() < 19 { + return Err(Error::HeadTooShort); + } let signature = buf.get_u64(); - anyhow::ensure!(signature == OPUS_HEAD, "invalid OpusHead signature"); + if signature != OPUS_HEAD { + return Err(Error::InvalidSignature); + } buf.advance(1); // Skip version let channel_count = buf.get_u8() as u32; diff --git a/rs/moq-mux/src/codec/vp8/import.rs b/rs/moq-mux/src/codec/vp8/import.rs index 8d5a94250..46f3fb56c 100644 --- a/rs/moq-mux/src/codec/vp8/import.rs +++ b/rs/moq-mux/src/codec/vp8/import.rs @@ -1,6 +1,7 @@ -use anyhow::Context; -use bytes::Buf; +use bytes::Bytes; +use crate::catalog::hang::CatalogExt; +use crate::container::Frame; use crate::container::jitter::Jitter; use super::FrameHeader; @@ -8,66 +9,50 @@ use super::FrameHeader; /// A frame-based importer for raw VP8. /// /// A VP8 elementary stream isn't self-delimiting, so the caller must pass whole -/// frames, one per [`decode_frame`](Self::decode_frame). The first key frame's -/// header supplies the catalog dimensions; the track is created lazily so the -/// importer can be constructed before any media arrives. -pub struct Import { - // Where new media tracks come from. - tracks: crate::track_provider::TrackProvider, +/// frames, one per [`decode`](Self::decode). The first key frame's header supplies +/// the catalog dimensions, so the rendition isn't published until then. Build it +/// with [`new`](Self::new), passing the track producer and the +/// [`catalog::Producer`](crate::catalog::Producer) it publishes into. +pub struct Import { + // The track being produced. + track: crate::container::Producer, - // The catalog being produced. - catalog: crate::catalog::Producer, - - // The track being produced, created on the first key frame. - track: Option>, + // This importer's catalog rendition, published on the first key frame. + rendition: crate::catalog::VideoTrack, // The resolved config, used to detect resolution changes. config: Option, - // Used to compute wall clock timestamps when the caller has none. - zero: Option, - // Tracks the minimum frame duration and updates the catalog `jitter` field. jitter: Jitter, } -impl Import { - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { +impl Import { + /// Publish on an existing track producer, registering the rendition in `catalog`. + pub fn new(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { + let rendition = catalog.video_track(track.name()); Self { - tracks: crate::track_provider::TrackProvider::unique(broadcast, ".vp8"), - catalog, - track: None, + track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), + rendition, config: None, - zero: None, - jitter: Jitter::new(), - } - } - - pub fn new_with_track(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { - Self { - tracks: crate::track_provider::TrackProvider::fixed(track), - catalog, - track: None, - config: None, - zero: None, jitter: Jitter::new(), } } /// Initialize the importer. /// - /// VP8 has no out-of-band configuration record, so this is normally called - /// with an empty buffer (gstreamer / ffi pass `Bytes::new()`) and the track - /// is created lazily from the first key frame. If the caller does pass the - /// first frame here, it's decoded so nothing is dropped. - pub fn initialize>(&mut self, buf: &mut T) -> anyhow::Result<()> { - if buf.has_remaining() { - self.decode_frame(buf, None)?; + /// VP8 has no out-of-band configuration record, so this is normally called with + /// an empty slice (gstreamer / ffi pass `&[]`) and the catalog is filled from the + /// first key frame. If the caller does pass the first frame here, it's decoded so + /// nothing is dropped. + pub fn initialize(&mut self, buf: &[u8]) -> crate::Result<()> { + if !buf.is_empty() { + self.decode(buf, None)?; } Ok(()) } - fn init(&mut self, width: u16, height: u16) -> anyhow::Result<()> { + fn init(&mut self, width: u16, height: u16) -> crate::Result<()> { let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); config.coded_width = Some(width as u32); config.coded_height = Some(height as u32); @@ -77,111 +62,57 @@ impl Import { return Ok(()); } - if self.track.is_some() && self.tracks.is_fixed() { - anyhow::bail!("fixed track cannot be reconfigured"); - } - - if let Some(track) = self.track.take() { - tracing::debug!(name = ?track.name, "reinitializing track"); - self.catalog.lock().video.renditions.remove(&track.name); - } - - let track = self.tracks.create()?; - tracing::debug!(name = ?track.name, ?config, "starting track"); - self.catalog - .lock() - .video - .renditions - .insert(track.name.clone(), config.clone()); - + tracing::debug!(name = ?self.track.name(), ?config, "starting track"); + self.rendition.set(config.clone()); self.config = Some(config); - self.track = Some(crate::container::Producer::new( - track, - crate::catalog::hang::Container::Legacy, - )); Ok(()) } /// Decode a single VP8 frame. - pub fn decode_frame>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - let payload = buf.copy_to_bytes(buf.remaining()); - anyhow::ensure!(!payload.is_empty(), "empty VP8 frame"); + pub fn decode(&mut self, frame: &[u8], pts: Option) -> crate::Result<()> { + if frame.is_empty() { + return Err(super::Error::EmptyFrame.into()); + } + let payload = Bytes::copy_from_slice(frame); let header = FrameHeader::parse(&payload)?; if let Some((width, height)) = header.dimensions { self.init(width, height)?; } - // Resolve the timestamp before borrowing `track` so `pts` doesn't hold a - // `&mut self` across the track write. - let pts = self.pts(pts)?; - let track = self - .track - .as_mut() - .context("expected a VP8 key frame before any interframe")?; - - track.write(crate::container::Frame { + let pts = self.rendition.timestamp(pts)?; + self.track.write(Frame { timestamp: pts, payload, keyframe: header.keyframe, + duration: None, })?; - if let Some(jitter) = self.jitter.observe(pts) - && let Some(c) = self.catalog.lock().video.renditions.get_mut(&track.name) - { - c.jitter = Some(jitter); + if let Some(jitter) = self.jitter.observe(pts) { + self.rendition + .update(|c| c.jitter = moq_net::Time::try_from(jitter).ok()); } Ok(()) } - /// Returns a reference to the underlying track producer. - pub fn track(&self) -> anyhow::Result<&moq_net::TrackProducer> { - Ok(self.track.as_ref().context("not initialized")?.track()) + /// A watch-only handle to this track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + self.track.track().demand() } /// Finish the track, flushing the current group. - pub fn finish(&mut self) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.finish()?; + pub fn finish(&mut self) -> crate::Result<()> { + self.track.finish()?; Ok(()) } /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.seek(sequence)?; + pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { + self.track.seek(sequence)?; Ok(()) } - - pub fn is_initialized(&self) -> bool { - self.track.is_some() - } - - fn pts(&mut self, hint: Option) -> anyhow::Result { - if let Some(pts) = hint { - return Ok(pts); - } - - let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); - Ok(crate::container::Timestamp::from_micros( - zero.elapsed().as_micros() as u64 - )?) - } -} - -impl Drop for Import { - fn drop(&mut self) { - if let Some(track) = self.track.take() { - tracing::debug!(name = ?track.name, "ending track"); - self.catalog.lock().video.renditions.remove(&track.name); - } - } } #[cfg(test)] @@ -190,26 +121,31 @@ mod tests { use crate::container::Timestamp; + fn setup() -> (moq_net::TrackProducer, crate::catalog::Producer) { + let mut broadcast = moq_net::Broadcast::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let track = broadcast.create_track(moq_net::Track::new("0.vp8")).unwrap(); + (track, catalog) + } + /// A 320x240 key frame followed by an interframe should create a single VP8 /// rendition with the right dimensions and emit both frames. #[tokio::test(start_paused = true)] async fn imports_keyframe_then_interframe() { - let mut broadcast = moq_net::Broadcast::new().produce(); - let mut catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); - let mut import = super::Import::new(broadcast.clone(), catalog.clone()); + let (track, catalog) = setup(); + let mut import = super::Import::new(track, catalog.clone()); - // Empty init buffer: the track is created lazily on the first key frame. - import.initialize(&mut Bytes::new()).unwrap(); - assert!(!import.is_initialized()); + // Empty init buffer: the catalog is filled on the first key frame. + import.initialize(&[]).unwrap(); + assert!(catalog.snapshot().video.renditions.is_empty()); let keyframe = Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]); import - .decode_frame(&mut keyframe.clone(), Some(Timestamp::from_micros(0).unwrap())) + .decode(&keyframe, Some(Timestamp::from_micros(0).unwrap())) .unwrap(); - assert!(import.is_initialized()); - let name = import.track().unwrap().name.clone(); - let config = catalog.lock().video.renditions.get(&name).cloned().unwrap(); + let snapshot = catalog.snapshot(); + let config = snapshot.video.renditions.get("0.vp8").unwrap(); assert_eq!(config.codec, hang::catalog::VideoCodec::VP8); assert_eq!(config.coded_width, Some(320)); assert_eq!(config.coded_height, Some(240)); @@ -217,24 +153,23 @@ mod tests { // Interframe: no start code or dimensions, but still a valid frame. let interframe = Bytes::from_static(&[0x31, 0x00, 0x00, 0xaa, 0xbb]); import - .decode_frame(&mut interframe.clone(), Some(Timestamp::from_micros(33_000).unwrap())) + .decode(&interframe, Some(Timestamp::from_micros(33_000).unwrap())) .unwrap(); import.finish().unwrap(); } - /// An interframe before any key frame has no dimensions, so the track can't - /// be created and the Producer rejects a non-keyframe first frame. + /// An interframe before any key frame has no dimensions, so the Producer + /// rejects a non-keyframe as the first frame in a group. #[tokio::test(start_paused = true)] async fn rejects_interframe_first() { - let mut broadcast = moq_net::Broadcast::new().produce(); - let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); - let mut import = super::Import::new(broadcast.clone(), catalog); + let (track, catalog) = setup(); + let mut import = super::Import::new(track, catalog); - let mut interframe = Bytes::from_static(&[0x31, 0x00, 0x00, 0xaa, 0xbb]); + let interframe = Bytes::from_static(&[0x31, 0x00, 0x00, 0xaa, 0xbb]); assert!( import - .decode_frame(&mut interframe, Some(Timestamp::from_micros(0).unwrap())) + .decode(&interframe, Some(Timestamp::from_micros(0).unwrap())) .is_err() ); } diff --git a/rs/moq-mux/src/codec/vp8/mod.rs b/rs/moq-mux/src/codec/vp8/mod.rs index d48e3b51e..da9559aed 100644 --- a/rs/moq-mux/src/codec/vp8/mod.rs +++ b/rs/moq-mux/src/codec/vp8/mod.rs @@ -11,6 +11,26 @@ mod import; pub use import::*; +/// VP8 parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("VP8 frame too short for tag")] + FrameTooShort, + + #[error("VP8 key frame too short for header")] + KeyframeHeaderTooShort, + + #[error("VP8 key frame start code mismatch")] + StartCodeMismatch, + + #[error("empty VP8 frame")] + EmptyFrame, +} + +/// A Result type alias for VP8 parsing. +pub type Result = std::result::Result; + /// Fields parsed from a VP8 frame tag (RFC 6386 §9.1) plus, for key frames, the /// key-frame header (§19.1). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -27,8 +47,10 @@ impl FrameHeader { /// Reads the 3-byte frame tag, and on a key frame the 7-byte header that /// follows (start code + dimensions). Interframes carry neither a start code /// nor dimensions. - pub fn parse(data: &[u8]) -> anyhow::Result { - anyhow::ensure!(data.len() >= 3, "VP8 frame too short for tag"); + pub fn parse(data: &[u8]) -> Result { + if data.len() < 3 { + return Err(Error::FrameTooShort); + } // 24-bit little-endian frame tag. Bit 0 is frame_type: 0 = key frame. let tag = u32::from(data[0]) | (u32::from(data[1]) << 8) | (u32::from(data[2]) << 16); @@ -41,11 +63,12 @@ impl FrameHeader { }); } - anyhow::ensure!(data.len() >= 10, "VP8 key frame too short for header"); - anyhow::ensure!( - data[3] == 0x9d && data[4] == 0x01 && data[5] == 0x2a, - "VP8 key frame start code mismatch" - ); + if data.len() < 10 { + return Err(Error::KeyframeHeaderTooShort); + } + if !(data[3] == 0x9d && data[4] == 0x01 && data[5] == 0x2a) { + return Err(Error::StartCodeMismatch); + } // 14-bit dimensions; the top 2 bits of each field are the scaling factor. let width = (u16::from(data[6]) | (u16::from(data[7]) << 8)) & 0x3fff; diff --git a/rs/moq-mux/src/codec/vp9/import.rs b/rs/moq-mux/src/codec/vp9/import.rs index e7ae4fb0d..11c32011a 100644 --- a/rs/moq-mux/src/codec/vp9/import.rs +++ b/rs/moq-mux/src/codec/vp9/import.rs @@ -1,6 +1,7 @@ -use anyhow::Context; -use bytes::Buf; +use bytes::Bytes; +use crate::catalog::hang::CatalogExt; +use crate::container::Frame; use crate::container::jitter::Jitter; use super::FrameHeader; @@ -8,66 +9,50 @@ use super::FrameHeader; /// A frame-based importer for raw VP9. /// /// Like VP8, a VP9 elementary stream isn't self-delimiting, so the caller must -/// pass whole frames (or superframes), one per -/// [`decode_frame`](Self::decode_frame). The first key frame's header supplies -/// the catalog config; the track is created lazily. -pub struct Import { - // Where new media tracks come from. - tracks: crate::track_provider::TrackProvider, +/// pass whole frames (or superframes), one per [`decode`](Self::decode). The first +/// key frame's header supplies the catalog config, so the rendition isn't published +/// until then. Build it with [`new`](Self::new), passing the track producer and the +/// [`catalog::Producer`](crate::catalog::Producer) it publishes into. +pub struct Import { + // The track being produced. + track: crate::container::Producer, - // The catalog being produced. - catalog: crate::catalog::Producer, - - // The track being produced, created on the first key frame. - track: Option>, + // This importer's catalog rendition, published on the first key frame. + rendition: crate::catalog::VideoTrack, // The resolved config, used to detect resolution / format changes. config: Option, - // Used to compute wall clock timestamps when the caller has none. - zero: Option, - // Tracks the minimum frame duration and updates the catalog `jitter` field. jitter: Jitter, } -impl Import { - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { - Self { - tracks: crate::track_provider::TrackProvider::unique(broadcast, ".vp09"), - catalog, - track: None, - config: None, - zero: None, - jitter: Jitter::new(), - } - } - - pub fn new_with_track(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { +impl Import { + /// Publish on an existing track producer, registering the rendition in `catalog`. + pub fn new(track: moq_net::TrackProducer, catalog: crate::catalog::Producer) -> Self { + let rendition = catalog.video_track(track.name()); Self { - tracks: crate::track_provider::TrackProvider::fixed(track), - catalog, - track: None, + track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), + rendition, config: None, - zero: None, jitter: Jitter::new(), } } /// Initialize the importer. /// - /// VP9 has no out-of-band configuration record, so this is normally called - /// with an empty buffer (gstreamer / ffi pass `Bytes::new()`) and the track - /// is created lazily from the first key frame. If the caller does pass the - /// first frame here, it's decoded so nothing is dropped. - pub fn initialize>(&mut self, buf: &mut T) -> anyhow::Result<()> { - if buf.has_remaining() { - self.decode_frame(buf, None)?; + /// VP9 has no out-of-band configuration record, so this is normally called with + /// an empty slice (gstreamer / ffi pass `&[]`) and the catalog is filled from the + /// first key frame. If the caller does pass the first frame here, it's decoded so + /// nothing is dropped. + pub fn initialize(&mut self, buf: &[u8]) -> crate::Result<()> { + if !buf.is_empty() { + self.decode(buf, None)?; } Ok(()) } - fn init(&mut self, vp9: hang::catalog::VP9, width: u16, height: u16) -> anyhow::Result<()> { + fn init(&mut self, vp9: hang::catalog::VP9, width: u16, height: u16) -> crate::Result<()> { let mut config = hang::catalog::VideoConfig::new(vp9); config.coded_width = Some(width as u32); config.coded_height = Some(height as u32); @@ -77,111 +62,57 @@ impl Import { return Ok(()); } - if self.track.is_some() && self.tracks.is_fixed() { - anyhow::bail!("fixed track cannot be reconfigured"); - } - - if let Some(track) = self.track.take() { - tracing::debug!(name = ?track.name, "reinitializing track"); - self.catalog.lock().video.renditions.remove(&track.name); - } - - let track = self.tracks.create()?; - tracing::debug!(name = ?track.name, ?config, "starting track"); - self.catalog - .lock() - .video - .renditions - .insert(track.name.clone(), config.clone()); - + tracing::debug!(name = ?self.track.name(), ?config, "starting track"); + self.rendition.set(config.clone()); self.config = Some(config); - self.track = Some(crate::container::Producer::new( - track, - crate::catalog::hang::Container::Legacy, - )); Ok(()) } /// Decode a single VP9 frame (or superframe). - pub fn decode_frame>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - let payload = buf.copy_to_bytes(buf.remaining()); - anyhow::ensure!(!payload.is_empty(), "empty VP9 frame"); + pub fn decode(&mut self, frame: &[u8], pts: Option) -> crate::Result<()> { + if frame.is_empty() { + return Err(super::Error::EmptyFrame.into()); + } + let payload = Bytes::copy_from_slice(frame); let header = FrameHeader::parse(&payload)?; if let Some(key) = header.key { self.init(key.to_catalog(), key.width, key.height)?; } - // Resolve the timestamp before borrowing `track` so `pts` doesn't hold a - // `&mut self` across the track write. - let pts = self.pts(pts)?; - let track = self - .track - .as_mut() - .context("expected a VP9 key frame before any interframe")?; - - track.write(crate::container::Frame { + let pts = self.rendition.timestamp(pts)?; + self.track.write(Frame { timestamp: pts, payload, keyframe: header.keyframe, + duration: None, })?; - if let Some(jitter) = self.jitter.observe(pts) - && let Some(c) = self.catalog.lock().video.renditions.get_mut(&track.name) - { - c.jitter = Some(jitter); + if let Some(jitter) = self.jitter.observe(pts) { + self.rendition + .update(|c| c.jitter = moq_net::Time::try_from(jitter).ok()); } Ok(()) } - /// Returns a reference to the underlying track producer. - pub fn track(&self) -> anyhow::Result<&moq_net::TrackProducer> { - Ok(self.track.as_ref().context("not initialized")?.track()) + /// A watch-only handle to this track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + self.track.track().demand() } /// Finish the track, flushing the current group. - pub fn finish(&mut self) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.finish()?; + pub fn finish(&mut self) -> crate::Result<()> { + self.track.finish()?; Ok(()) } /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - let track = self.track.as_mut().context("not initialized")?; - track.seek(sequence)?; + pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { + self.track.seek(sequence)?; Ok(()) } - - pub fn is_initialized(&self) -> bool { - self.track.is_some() - } - - fn pts(&mut self, hint: Option) -> anyhow::Result { - if let Some(pts) = hint { - return Ok(pts); - } - - let zero = self.zero.get_or_insert_with(tokio::time::Instant::now); - Ok(crate::container::Timestamp::from_micros( - zero.elapsed().as_micros() as u64 - )?) - } -} - -impl Drop for Import { - fn drop(&mut self) { - if let Some(track) = self.track.take() { - tracing::debug!(name = ?track.name, "ending track"); - self.catalog.lock().video.renditions.remove(&track.name); - } - } } #[cfg(test)] @@ -193,35 +124,34 @@ mod tests { // profile 0, 8-bit, CS_BT_601, studio range, 4:2:0, 320x240. const KEYFRAME: &[u8] = &[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]; + fn setup() -> (moq_net::TrackProducer, crate::catalog::Producer) { + let mut broadcast = moq_net::Broadcast::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let track = broadcast.create_track(moq_net::Track::new("0.vp9")).unwrap(); + (track, catalog) + } + #[tokio::test(start_paused = true)] async fn imports_keyframe_then_interframe() { - let mut broadcast = moq_net::Broadcast::new().produce(); - let mut catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); - let mut import = super::Import::new(broadcast.clone(), catalog.clone()); + let (track, catalog) = setup(); + let mut import = super::Import::new(track, catalog.clone()); - import.initialize(&mut Bytes::new()).unwrap(); - assert!(!import.is_initialized()); + import.initialize(&[]).unwrap(); + assert!(catalog.snapshot().video.renditions.is_empty()); import - .decode_frame( - &mut Bytes::from_static(KEYFRAME), - Some(Timestamp::from_micros(0).unwrap()), - ) + .decode(KEYFRAME, Some(Timestamp::from_micros(0).unwrap())) .unwrap(); - assert!(import.is_initialized()); - let name = import.track().unwrap().name.clone(); - let config = catalog.lock().video.renditions.get(&name).cloned().unwrap(); + let snapshot = catalog.snapshot(); + let config = snapshot.video.renditions.get("0.vp9").unwrap(); assert!(matches!(config.codec, hang::catalog::VideoCodec::VP9(_))); assert_eq!(config.coded_width, Some(320)); assert_eq!(config.coded_height, Some(240)); // Interframe: marker(10) profile(00) show_existing(0) frame_type(1) = 0x84. import - .decode_frame( - &mut Bytes::from_static(&[0x84, 0x00, 0x00]), - Some(Timestamp::from_micros(33_000).unwrap()), - ) + .decode(&[0x84, 0x00, 0x00], Some(Timestamp::from_micros(33_000).unwrap())) .unwrap(); import.finish().unwrap(); @@ -229,14 +159,13 @@ mod tests { #[tokio::test(start_paused = true)] async fn rejects_interframe_first() { - let mut broadcast = moq_net::Broadcast::new().produce(); - let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); - let mut import = super::Import::new(broadcast.clone(), catalog); + let (track, catalog) = setup(); + let mut import = super::Import::new(track, catalog); - let mut interframe = Bytes::from_static(&[0x84, 0x00, 0x00]); + let interframe = Bytes::from_static(&[0x84, 0x00, 0x00]); assert!( import - .decode_frame(&mut interframe, Some(Timestamp::from_micros(0).unwrap())) + .decode(&interframe, Some(Timestamp::from_micros(0).unwrap())) .is_err() ); } diff --git a/rs/moq-mux/src/codec/vp9/mod.rs b/rs/moq-mux/src/codec/vp9/mod.rs index 816358abf..a08f1822f 100644 --- a/rs/moq-mux/src/codec/vp9/mod.rs +++ b/rs/moq-mux/src/codec/vp9/mod.rs @@ -14,6 +14,26 @@ pub use import::*; use hang::catalog::VP9; +/// VP9 parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("invalid VP9 frame marker")] + InvalidFrameMarker, + + #[error("invalid VP9 sync code")] + InvalidSyncCode, + + #[error("VP9 header truncated")] + Truncated, + + #[error("empty VP9 frame")] + EmptyFrame, +} + +/// A Result type alias for VP9 parsing. +pub type Result = std::result::Result; + /// VP9 key-frame sync code (VP9 spec §6.2, `frame_sync_code`). const SYNC_CODE: u32 = 0x49_8342; @@ -46,10 +66,12 @@ impl FrameHeader { /// /// Reads only as far as the frame size (the last field we care about); /// everything after it is left untouched. - pub fn parse(data: &[u8]) -> anyhow::Result { + pub fn parse(data: &[u8]) -> Result { let mut r = BitReader::new(data); - anyhow::ensure!(r.read(2)? == 0b10, "invalid VP9 frame marker"); + if r.read(2)? != 0b10 { + return Err(Error::InvalidFrameMarker); + } let profile_low = r.read(1)?; let profile_high = r.read(1)?; @@ -77,7 +99,9 @@ impl FrameHeader { }); } - anyhow::ensure!(r.read(24)? == SYNC_CODE, "invalid VP9 sync code"); + if r.read(24)? != SYNC_CODE { + return Err(Error::InvalidSyncCode); + } // color_config (VP9 spec §6.2.2). let bit_depth = if profile >= 2 { @@ -148,13 +172,14 @@ impl KeyFrame { } } -/// Build a catalog [`VideoConfig`](hang::catalog::VideoConfig) from a VP9 key -/// frame's uncompressed header, or `None` if `data` is not a key frame. +/// Build a catalog [`VideoConfig`](hang::catalog::VideoConfig) from a VP9 frame, +/// or `None` if the frame is not a key frame. /// -/// VP9 has no out-of-band config record (the FLV `vp09` shape configures the -/// decoder in band), so the enhanced-RTMP importer derives the config from each -/// key frame instead of a sequence-header tag. -pub(crate) fn config_from_keyframe(data: &[u8]) -> anyhow::Result> { +/// Used by the enhanced-RTMP / FLV importer. VP9 carries its config in band (the +/// uncompressed key-frame header), so unlike H.264/H.265/AV1 there is no +/// out-of-band record to pass through as `description`; the config is read from +/// the key frame itself. +pub(crate) fn config_from_keyframe(data: &[u8]) -> Result> { let Some(key) = FrameHeader::parse(data)?.key else { return Ok(None); }; @@ -245,11 +270,13 @@ impl<'a> BitReader<'a> { Self { data, bit: 0 } } - fn read(&mut self, n: u32) -> anyhow::Result { + fn read(&mut self, n: u32) -> Result { let mut value = 0; for _ in 0..n { let byte = self.bit / 8; - anyhow::ensure!(byte < self.data.len(), "VP9 header truncated"); + if byte >= self.data.len() { + return Err(Error::Truncated); + } let shift = 7 - (self.bit % 8); value = (value << 1) | u32::from((self.data[byte] >> shift) & 1); self.bit += 1; @@ -257,7 +284,7 @@ impl<'a> BitReader<'a> { Ok(value) } - fn skip(&mut self, n: u32) -> anyhow::Result<()> { + fn skip(&mut self, n: u32) -> Result<()> { self.read(n).map(|_| ()) } } diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 9a86db4b4..73dd6bda6 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -1,7 +1,8 @@ use std::collections::VecDeque; use std::task::{Poll, ready}; -use super::{Container, Frame, Timestamp}; +use super::Timestamp; +use super::{Container, Frame}; /// Decode a moq-lite track into a stream of media [`Frame`]s in latency-bounded /// presentation order. @@ -17,10 +18,16 @@ use super::{Container, Frame, Timestamp}; /// a group in arrival order, but across groups it advances by sequence number, skipping /// stalled or missing groups when the difference between the oldest pending timestamp /// and the newest available timestamp exceeds the configured latency. With the default -/// latency of zero, the consumer skips aggressively — any group that has a newer +/// latency of zero, the consumer skips aggressively. Any group that has a newer /// alternative is dropped. With a non-zero latency, slow groups are tolerated up to that /// budget before being skipped. /// +/// A stalled group is also skipped early, regardless of the latency budget, once it has +/// presented up to where the next group begins. CMAF frames carry a per-sample duration, +/// so a group whose most recent frame ends (timestamp + duration) at or past the next +/// group's first timestamp has nothing left worth waiting for. Containers without a +/// duration report zero, which disables this check and falls back to the latency budget. +/// /// Set the latency with [`with_latency`](Self::with_latency) (builder) or /// [`set_latency`](Self::set_latency) (mid-stream). /// @@ -218,37 +225,45 @@ impl Consumer { // Still blocked on this group, don't skip it yet. Poll::Pending => break, Poll::Ready(Err(e)) => { - tracing::warn!(error = ?e, "error reading current group, skipping"); + // The group was dropped/aborted -- typically it aged out of the relay + // cache (`Error::Old`) while we weren't reading it. Any sequences + // between it and the next buffered group were evicted alongside it, so + // jump straight to that group instead of stepping one-by-one and then + // blocking on a sequence gap of groups that will never arrive. + tracing::warn!(error = ?e, "current group dropped; skipping to next buffered group"); + self.pending.pop_front(); + self.current = self.pending.front().map_or(self.current + 1, |g| g.sequence); + } + // Cleanly finished group: advance to the next sequence. + Poll::Ready(Ok(None)) => { + self.pending.pop_front(); + self.current += 1; } - // No more frames, advance to next group. - Poll::Ready(Ok(None)) => {} } - - self.pending.pop_front(); - self.current += 1 } - // Get the current group's min timestamp as the reference for latency comparison. - let oldest_timestamp = if let Some(current) = self.pending.front_mut() + // Get the current group's min timestamp (the reference for latency + // comparison) and its furthest presentation point (timestamp + duration). + let (oldest_timestamp, current_end) = if let Some(current) = self.pending.front_mut() && current.sequence <= self.current { match current.poll_min_timestamp(waiter, &self.format) { - Poll::Ready(Ok(ts)) => Some::(ts.into()), - _ => None, + Poll::Ready(Ok(ts)) => (Some(std::time::Duration::from(ts)), current.max_end), + _ => (None, None), } } else { - None + (None, None) }; - // Find the first newer group with data (our skip target). - let mut min_idx = None; + // Find the first newer group with data (our skip target) and where it starts. + let mut next_group = None; for (i, group) in self.pending.iter_mut().enumerate() { if group.sequence <= self.current { continue; } - if let Poll::Ready(Ok(_)) = group.poll_min_timestamp(waiter, &self.format) { - min_idx = Some(i); + if let Poll::Ready(Ok(ts)) = group.poll_min_timestamp(waiter, &self.format) { + next_group = Some((i, std::time::Duration::from(ts))); break; } } @@ -266,20 +281,29 @@ impl Consumer { } } - let should_skip = if min_idx.is_some() { + let should_skip = if let Some((_, next_start)) = next_group { if let Some(oldest) = oldest_timestamp { - // Current group is blocking: skip if newer groups exceed latency threshold - max_timestamp.saturating_sub(oldest) >= self.latency + // Current group is blocking. Skip if newer groups have pulled past + // the latency budget, or if the current group has already presented + // up to where the next group begins (duration coverage) so there's + // nothing left worth waiting for. + let over_latency = max_timestamp.saturating_sub(oldest) >= self.latency; + let covered = current_end.is_some_and(|end| end >= next_start); + over_latency || covered } else { - // Sequence gap: current group consumed but next sequence missing. - // Only skip if track is fully received (no more groups coming). - finished + // The current group can't produce a timestamp: either it's missing + // entirely -- a lower sequence the cache evicted, so `front` is already + // past `current` -- or it's finished/empty. With a newer group buffered, + // skip if the track is done OR the current sequence is simply gone. On a + // live track a buffered higher sequence means the missing one was evicted + // (the relay delivers in order), not merely late, so waiting is futile. + finished || self.pending.front().is_some_and(|g| g.sequence > self.current) } } else { false }; - if let Some(new_idx) = min_idx + if let Some((new_idx, _)) = next_group && should_skip { self.pending.drain(0..new_idx); @@ -468,6 +492,11 @@ struct GroupBuffer { // The maximum timestamp in the group. max_timestamp: Option, + + // The furthest presentation point reached so far, i.e. max(timestamp + duration). + // Equals the max timestamp when the container carries no per-frame duration. + // Stored as a wall-clock duration so cross-scale comparisons are cheap. + max_end: Option, } impl GroupBuffer { @@ -478,6 +507,7 @@ impl GroupBuffer { buffered: VecDeque::new(), max_timestamp: None, min_timestamp: None, + max_end: None, } } @@ -501,7 +531,7 @@ impl GroupBuffer { return Poll::Ready(Ok(false)); }; - for frame in frames { + for mut frame in frames { self.min_timestamp = Some(match self.min_timestamp { Some(existing) => existing.min(frame.timestamp), None => frame.timestamp, @@ -512,16 +542,22 @@ impl GroupBuffer { None => frame.timestamp, }); + // Furthest presentation point, in wall-clock terms so timestamp and + // duration can be at different scales without extra conversions. A frame + // with no duration contributes only its timestamp. + let duration = frame.duration.map(std::time::Duration::from).unwrap_or_default(); + let end = std::time::Duration::from(frame.timestamp) + duration; + self.max_end = Some(match self.max_end { + Some(existing) => existing.max(end), + None => end, + }); + // First frame of a group is always a keyframe by protocol invariant; trust // the container's flag otherwise so CMAF mid-group keyframes survive. - let keyframe = frame.keyframe || self.index == 0; + frame.keyframe = frame.keyframe || self.index == 0; self.index += 1; - self.buffered.push_back(Frame { - timestamp: frame.timestamp, - payload: frame.payload, - keyframe, - }); + self.buffered.push_back(frame); } Poll::Ready(Ok(true)) @@ -600,13 +636,72 @@ mod tests { use bytes::Bytes; + /// Mint a standalone track for tests via a throwaway broadcast, since tracks are + /// born from their broadcast (no public `TrackProducer::new`). + fn track_producer(name: impl Into) -> moq_net::TrackProducer { + moq_net::Broadcast::new() + .produce() + .create_track(moq_net::Track::new(name)) + .unwrap() + } + fn ts(micros: u64) -> Timestamp { Timestamp::from_micros(micros).unwrap() } - /// Subscribe to a track with default preferences (test helper). - fn subscribe_default(track: &moq_net::TrackProducer) -> moq_net::TrackConsumer { - track.consume() + /// Test-only container that round-trips a per-sample duration on the wire, so the + /// duration-based skip can be exercised without building a real CMAF init segment. + /// Each frame is `[timestamp_us: u64 LE][duration_us: u64 LE][payload]`. + struct DurationWire; + + /// Encode a `[timestamp][duration][payload]` DurationWire frame. + fn encode_duration_frame(timestamp: Timestamp, duration: Timestamp) -> Vec { + let mut buf = Vec::with_capacity(18); + buf.extend_from_slice(&(timestamp.as_micros() as u64).to_le_bytes()); + buf.extend_from_slice(&(duration.as_micros() as u64).to_le_bytes()); + buf.extend_from_slice(&[0xDE, 0xAD]); + buf + } + + impl ContainerTrait for DurationWire { + type Error = crate::Error; + + fn write(&self, group: &mut moq_net::GroupProducer, frames: &[Frame]) -> Result<(), Self::Error> { + // The duration tests write frames directly via `write_duration_frame`; + // this path just preserves the timestamp with an unknown duration. + for frame in frames { + group.write_frame(encode_duration_frame(frame.timestamp, ts(0)))?; + } + Ok(()) + } + + fn poll_read( + &self, + group: &mut moq_net::GroupConsumer, + waiter: &kio::Waiter, + ) -> Poll>, Self::Error>> { + use bytes::Buf; + + let Some(mut data) = ready!(group.poll_read_frame(waiter)?) else { + return Poll::Ready(Ok(None)); + }; + + let timestamp = ts(data.get_u64_le()); + let duration = ts(data.get_u64_le()); + let payload = data.copy_to_bytes(data.remaining()); + + Poll::Ready(Ok(Some(vec![Frame { + timestamp, + payload, + keyframe: false, + duration: Some(duration), + }]))) + } + } + + /// Write one DurationWire frame (timestamp and duration in µs) into a group. + fn write_duration_frame(group: &mut moq_net::GroupProducer, timestamp: Timestamp, duration: Timestamp) { + group.write_frame(encode_duration_frame(timestamp, duration)).unwrap(); } /// Write a finished group with explicit sequence and timestamps (Container::Legacy format). @@ -617,6 +712,7 @@ mod tests { timestamp, payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }; Container::Legacy.write(&mut group, &[frame]).unwrap(); } @@ -644,8 +740,8 @@ mod tests { #[tokio::test] async fn read_single_group() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); write_group(&mut track, 0, &[ts(0)]); @@ -662,8 +758,8 @@ mod tests { #[tokio::test] async fn read_multiple_frames_single_group() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); write_group(&mut track, 0, &[ts(0), ts(33_000), ts(66_000)]); @@ -680,8 +776,8 @@ mod tests { #[tokio::test] async fn read_multiple_groups_within_latency() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); // 5 groups, 20ms spacing. Total span = 80ms, well within 500ms latency. @@ -699,8 +795,8 @@ mod tests { #[tokio::test] async fn latency_skip_delivers_recent_groups() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(100)); // Group 0: 5 frames, NOT finished (blocks consumer) @@ -713,6 +809,7 @@ mod tests { timestamp: ts(f * 2_000), payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -740,8 +837,8 @@ mod tests { #[tokio::test] async fn zero_latency_skips_aggressively() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::ZERO); // Group 0 at ts 0 keeps timestamps monotonic with sequence (groups 1-9 follow at @@ -754,6 +851,7 @@ mod tests { timestamp: ts(0), payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -778,8 +876,8 @@ mod tests { #[tokio::test] async fn latency_skip_correctness() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(100)); let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); @@ -790,6 +888,7 @@ mod tests { timestamp: ts(0), payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -846,8 +945,8 @@ mod tests { #[tokio::test] async fn reset_keeps_out_of_order_new_group() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_secs(10)); // Old epoch, played forward until the live edge passes the rewind point. @@ -885,8 +984,8 @@ mod tests { #[tokio::test] async fn reset_detected_behind_forward_newest_group() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_secs(10)); // Old timeline, played to a live edge of 200 ms. @@ -920,8 +1019,8 @@ mod tests { #[tokio::test] async fn backwards_timestamp_resets_buffer() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); // Large latency so the slow-group skip never fires; isolate the rewind path. let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_secs(10)); @@ -946,8 +1045,8 @@ mod tests { /// configuration. Here group 2 rewinds the timeline and bumps the discontinuity counter. #[tokio::test] async fn backwards_timestamp_always_resets() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_secs(10)); write_group(&mut track, 0, &[ts(0)]); @@ -967,8 +1066,8 @@ mod tests { #[tokio::test] async fn groups_delivered_in_sequence_order() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); @@ -979,6 +1078,7 @@ mod tests { timestamp: ts(0), payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -1002,8 +1102,8 @@ mod tests { #[tokio::test] async fn adjacent_group_flushed_immediately() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); write_group(&mut track, 0, &[ts(0)]); @@ -1020,8 +1120,8 @@ mod tests { #[tokio::test] async fn bframes_within_group() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); write_group(&mut track, 0, &[ts(0), ts(66_000), ts(33_000)]); @@ -1039,8 +1139,8 @@ mod tests { #[tokio::test] async fn empty_track_returns_none() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); track.finish().unwrap(); @@ -1057,8 +1157,8 @@ mod tests { #[tokio::test] async fn track_closed_with_error() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); write_group(&mut track, 0, &[ts(0)]); @@ -1079,8 +1179,8 @@ mod tests { #[tokio::test] async fn closed_resolves_when_track_ends() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); assert!( @@ -1102,8 +1202,8 @@ mod tests { #[tokio::test] async fn gap_in_group_sequence_recovery() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(100)); write_group(&mut track, 0, &[ts(0), ts(20_000)]); @@ -1120,8 +1220,8 @@ mod tests { #[tokio::test] async fn gap_at_start_of_sequence() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(80)); write_group(&mut track, 5, &[ts(0), ts(20_000)]); @@ -1134,12 +1234,86 @@ mod tests { assert!(frames.len() >= 4, "Expected >= 4 frames, got {}", frames.len()); } + // ---- Eviction recovery (pause/resume) ---- + + /// A group that aged out of the relay cache (aborted with `Error::Old`) while the + /// consumer was parked on it must not hang the consumer: reading it errors, and + /// the consumer skips the gap to the next live group even though the track is NOT + /// finished. This is the resume-from-pause path (the recorder stops reading, the + /// group + the sequences after it evict, then it resumes). + #[tokio::test] + async fn evicted_group_with_gap_skips_to_live() { + let mut track = track_producer("test"); + let consumer_track = track.consume(); + let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(100)); + + // Group 0: a frame the consumer reads, positioning it there. + let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); + Container::Legacy + .write( + &mut group0, + &[Frame { + timestamp: ts(0), + payload: Bytes::from_static(&[0xDE, 0xAD]), + keyframe: false, + duration: None, + }], + ) + .unwrap(); + let first = consumer.read().await.unwrap().unwrap(); + assert_eq!(first.timestamp, ts(0)); + + // A live group arrives far ahead -- sequences 1..4 never come (evicted). The + // track stays OPEN (not finished), the failure mode that used to hang. + write_group(&mut track, 5, &[ts(150_000)]); + + // Group 0 ages out of the cache (the relay aborts it on eviction). + group0.abort(moq_net::Error::Old).unwrap(); + + // Must skip the evicted group + the gap and reach the live group, without + // hanging on a track that never finishes. + let next = tokio::time::timeout(Duration::from_secs(1), consumer.read()) + .await + .expect("consumer hung on an evicted group / gap") + .unwrap() + .unwrap(); + assert_eq!(next.timestamp, ts(150_000), "skipped the evicted gap to the live group"); + } + + /// A missing (evicted) sequence with a newer group buffered must be skipped even + /// while the track is still LIVE -- not only once it's finished. This is the + /// recorder resume stall: `current` points at a sequence the cache dropped, a + /// higher group is buffered, and the track never finishes. + #[tokio::test] + async fn missing_sequence_skips_on_live_track() { + let mut track = track_producer("test"); + let consumer_track = track.consume(); + let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(100)); + + // Group 0, then group 2 -- sequence 1 is missing (evicted) and never arrives. + // The track is NOT finished (live), the case that used to hang. + write_group(&mut track, 0, &[ts(0), ts(20_000)]); + write_group(&mut track, 2, &[ts(200_000)]); + + // Reading must reach group 2 across the gap instead of waiting forever for 1. + let reached = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let frame = consumer.read().await.unwrap().unwrap(); + if frame.timestamp == ts(200_000) { + return; + } + } + }) + .await; + assert!(reached.is_ok(), "consumer hung on a missing sequence on a live track"); + } + // ---- Frame Decoding ---- #[tokio::test] async fn frame_timestamp_and_index_decoding() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); write_group(&mut track, 0, &[ts(0), ts(33_333), ts(66_666)]); @@ -1158,8 +1332,8 @@ mod tests { #[tokio::test] async fn frame_payload_preserved() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); let payload_bytes = vec![0x01, 0x02, 0x03, 0x04, 0x05]; @@ -1172,6 +1346,7 @@ mod tests { payload: Bytes::from(payload_bytes.clone()), keyframe: false, + duration: None, }], ) .unwrap(); @@ -1195,8 +1370,8 @@ mod tests { #[tokio::test] async fn no_infinite_loop_with_buffered_frames() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_secs(10)); let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); @@ -1207,6 +1382,7 @@ mod tests { timestamp: ts(0), payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -1240,8 +1416,8 @@ mod tests { #[tokio::test] async fn large_timestamps() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_secs(3700)); let one_hour = 3_600_000_000u64; @@ -1256,8 +1432,8 @@ mod tests { #[tokio::test] async fn set_latency_changes_behavior() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_secs(10)); write_group(&mut track, 0, &[ts(0)]); @@ -1274,7 +1450,7 @@ mod tests { #[tokio::test] async fn max_timestamp_tracks_through_bframes() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); + let mut track = track_producer("test"); let consumer_track = track.consume(); // latency must exceed (group1_max - group0_min) = 100ms - 0ms = 100ms // to avoid the latency skip and test B-frame timestamp tracking. @@ -1289,6 +1465,7 @@ mod tests { timestamp, payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -1325,8 +1502,8 @@ mod tests { #[tokio::test] async fn startup_selects_earliest_group() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(100)); write_group(&mut track, 3, &[ts(0)]); @@ -1340,6 +1517,7 @@ mod tests { timestamp: ts(300_000), payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -1353,6 +1531,7 @@ mod tests { timestamp: ts(400_000), payload: Bytes::from_static(&[0xBE, 0xEF]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -1376,8 +1555,8 @@ mod tests { #[tokio::test] async fn startup_skips_groups_without_data() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); let _group5 = track.create_group(moq_net::Group { sequence: 5 }).unwrap(); @@ -1399,8 +1578,8 @@ mod tests { #[tokio::test] async fn startup_single_group_mid_stream() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); write_group(&mut track, 100, &[ts(3_000_000)]); @@ -1413,8 +1592,8 @@ mod tests { #[tokio::test] async fn multiple_sequential_latency_skips() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(50)); let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); @@ -1426,6 +1605,7 @@ mod tests { payload: Bytes::from_static(&[0xAA]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -1448,8 +1628,8 @@ mod tests { #[tokio::test] async fn latency_skip_boundary_exact() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(100)); let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); @@ -1461,6 +1641,7 @@ mod tests { payload: Bytes::from_static(&[0xAA]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -1485,8 +1666,8 @@ mod tests { #[tokio::test] async fn single_newer_group_triggers_skip() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(100)); // Group 0: stalled at ts=0, NOT finished @@ -1498,6 +1679,7 @@ mod tests { timestamp: ts(0), payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }], ) .unwrap(); @@ -1522,8 +1704,8 @@ mod tests { #[tokio::test] async fn single_missing_sequence_near_eof_skips() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(100)); // Group 0: finished normally @@ -1538,8 +1720,8 @@ mod tests { #[tokio::test] async fn group_error_skips_to_next() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); @@ -1555,8 +1737,8 @@ mod tests { #[tokio::test] async fn track_finishes_while_reading() { tokio::time::pause(); - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); write_group(&mut track, 0, &[ts(0)]); @@ -1584,8 +1766,8 @@ mod tests { #[tokio::test] async fn empty_group_advances() { - let mut track = moq_net::Track::new("test").produce(); - let consumer_track = subscribe_default(&track); + let mut track = track_producer("test"); + let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); @@ -1604,7 +1786,7 @@ mod tests { async fn video_container_legacy() { tokio::time::pause(); - let mut track = moq_net::Track::new("video").produce(); + let mut track = track_producer("video"); let consumer_track = track.consume(); let mut consumer = Consumer::new(consumer_track, Container::Legacy).with_latency(Duration::from_millis(500)); @@ -1615,6 +1797,7 @@ mod tests { timestamp: ts(i * 33_333), payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe: false, + duration: None, }; Container::Legacy.write(&mut group, &[frame]).unwrap(); } @@ -1634,4 +1817,95 @@ mod tests { assert_eq!(frames[2].timestamp, ts(66_666)); assert!(!frames[2].keyframe); } + + // ---- Duration Skipping ---- + + /// A stalled group whose frame covers up to the next group's start is skipped + /// immediately, even with a latency budget far larger than the gap. Without + /// duration support the consumer would block on the unfinished group forever. + #[tokio::test] + async fn duration_skip_advances_to_next_group() { + tokio::time::pause(); + // DurationWire is a test-only container that doesn't stamp moq_net frame + // timestamps; leave the track untimed so model-layer validation matches. + let mut track = track_producer("test"); + let consumer_track = track.consume(); + // Latency dwarfs the gap, so only duration coverage can trigger the skip. + let mut consumer = Consumer::new(consumer_track, DurationWire).with_latency(Duration::from_secs(10)); + + // Group 0: one frame at ts=0 lasting 33ms, never finished. + let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); + write_duration_frame(&mut group0, ts(0), ts(33_000)); + + // Group 1: finished, starts exactly where group 0's frame ends. + let mut group1 = track.create_group(moq_net::Group { sequence: 1 }).unwrap(); + write_duration_frame(&mut group1, ts(33_000), ts(33_000)); + group1.finish().unwrap(); + + track.finish().unwrap(); + + let frames = tokio::time::timeout(Duration::from_secs(2), async { + let mut frames = Vec::new(); + while let Some(frame) = consumer.read().await.unwrap() { + frames.push(frame); + } + frames + }) + .await + .expect("consumer hung — duration skip regression"); + + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].timestamp, ts(0)); + assert_eq!(frames[1].timestamp, ts(33_000)); + + // group0 is intentionally never finished. + drop(group0); + } + + /// When the current group's frame ends before the next group begins, there is + /// still a gap to cover, so we don't skip early: a late-arriving frame on the + /// slow group is delivered rather than dropped. + #[tokio::test] + async fn duration_below_gap_does_not_skip() { + tokio::time::pause(); + // DurationWire is untimed at the moq_net frame layer. + let mut track = track_producer("test"); + let consumer_track = track.consume(); + let mut consumer = Consumer::new(consumer_track, DurationWire).with_latency(Duration::from_secs(10)); + + // Group 0: frame at ts=0 lasting only 10ms, far short of group 1 at 33ms. + let mut group0 = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); + write_duration_frame(&mut group0, ts(0), ts(10_000)); + + // Group 1: finished at 33ms. + let mut group1 = track.create_group(moq_net::Group { sequence: 1 }).unwrap(); + write_duration_frame(&mut group1, ts(33_000), ts(33_000)); + group1.finish().unwrap(); + track.finish().unwrap(); + + // A second frame lands on group 0 and finishes it after the consumer has + // had a chance to (incorrectly) skip. + let finisher = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + write_duration_frame(&mut group0, ts(20_000), ts(10_000)); + group0.finish().unwrap(); + }); + + let frames = tokio::time::timeout(Duration::from_secs(2), async { + let mut frames = Vec::new(); + while let Some(frame) = consumer.read().await.unwrap() { + frames.push(frame); + } + frames + }) + .await + .expect("consumer hung"); + + // The slow group's late frame survives because nothing covered the gap. + assert_eq!(frames.len(), 3); + assert_eq!(frames[0].timestamp, ts(0)); + assert_eq!(frames[1].timestamp, ts(20_000)); + assert_eq!(frames[2].timestamp, ts(33_000)); + finisher.await.unwrap(); + } } diff --git a/rs/moq-mux/src/container/flv/export.rs b/rs/moq-mux/src/container/flv/export.rs index 0cd2da443..6abc39842 100644 --- a/rs/moq-mux/src/container/flv/export.rs +++ b/rs/moq-mux/src/container/flv/export.rs @@ -24,8 +24,8 @@ use super::{ TAG_HEADER_LEN, TAG_VIDEO, VIDEO_CODEC_AVC, VIDEO_EX_HEADER, VIDEO_PACKET_CODED_FRAMES, VIDEO_PACKET_SEQUENCE_START, }; -use crate::catalog::CatalogFormat; -use crate::container::{CatalogSource, ExportSource, Frame}; +use crate::catalog::{CatalogFormat, Stream}; +use crate::container::{ExportSource, Frame}; /// Which FLV payload shape a bound track is muxed as: a legacy CodecID /// (`Avc`/`Aac`) or an enhanced-RTMP FourCC codec. @@ -73,7 +73,7 @@ impl Flavor { /// supported; CMAF tracks are rejected. pub struct Export { broadcast: moq_net::BroadcastConsumer, - catalog: Option, + catalog: Option, latency: Duration, video: Option, @@ -111,7 +111,7 @@ impl Export { broadcast: moq_net::BroadcastConsumer, catalog_format: CatalogFormat, ) -> Result { - let catalog = CatalogSource::new(&broadcast, catalog_format)?; + let catalog = crate::catalog::Consumer::new(&broadcast, catalog_format)?; Ok(Self { broadcast, catalog: Some(catalog), @@ -168,7 +168,7 @@ impl Export { track.finished = true; break; } - Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Ready(Err(e)) => return Poll::Ready(Err(e.into())), Poll::Pending => break, } } diff --git a/rs/moq-mux/src/container/flv/export_test.rs b/rs/moq-mux/src/container/flv/export_test.rs index dcf3b60c6..7b24f00d3 100644 --- a/rs/moq-mux/src/container/flv/export_test.rs +++ b/rs/moq-mux/src/container/flv/export_test.rs @@ -110,15 +110,12 @@ async fn drain_export(mut exporter: Export, mut importer: Import) -> Vec { #[tokio::test(start_paused = true)] async fn export_roundtrips_through_import() { - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); + let mut producer = moq_net::Broadcast::new().produce(); let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = Import::new(producer, catalog.clone()); - importer - .decode(&mut bytes::BytesMut::from(synth_flv().as_slice())) - .unwrap(); + importer.decode(&bytes::BytesMut::from(synth_flv().as_slice())).unwrap(); catalog.finish().unwrap(); let exporter = Export::new(consumer).unwrap(); @@ -131,7 +128,7 @@ async fn export_roundtrips_through_import() { let mut bcast2 = moq_net::Broadcast::new().produce(); let cat2 = crate::catalog::Producer::new(&mut bcast2).unwrap(); let mut imp2 = Import::new(bcast2, cat2.clone()); - imp2.decode(&mut bytes::BytesMut::from(exported.as_slice())).unwrap(); + imp2.decode(&bytes::BytesMut::from(exported.as_slice())).unwrap(); imp2.finish().unwrap(); let snap = cat2.snapshot(); @@ -150,15 +147,12 @@ async fn export_roundtrips_through_import() { #[tokio::test(start_paused = true)] async fn export_emits_sequence_headers_and_frames() { - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); + let mut producer = moq_net::Broadcast::new().produce(); let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = Import::new(producer, catalog.clone()); - importer - .decode(&mut bytes::BytesMut::from(synth_flv().as_slice())) - .unwrap(); + importer.decode(&bytes::BytesMut::from(synth_flv().as_slice())).unwrap(); catalog.finish().unwrap(); let exporter = Export::new(consumer).unwrap(); @@ -191,61 +185,6 @@ async fn export_emits_sequence_headers_and_frames() { assert_eq!(audio_frames, 2, "expected two audio frames"); } -struct ParsedTag { - tag_type: u8, - timestamp: u32, - body: Vec, -} - -/// Parse an FLV byte stream into its tags (skipping the 9-byte file header and -/// every `PreviousTagSize`). -fn parse_tags(flv: &[u8]) -> Vec { - let mut tags = Vec::new(); - let mut off = 9 + 4; // file header + PreviousTagSize0 - while off + 11 <= flv.len() { - let tag_type = flv[off]; - let size = super::read_u24(&flv[off + 1..off + 4]) as usize; - let timestamp = super::read_u24(&flv[off + 4..off + 7]) | ((flv[off + 7] as u32) << 24); - let body_start = off + 11; - if body_start + size + 4 > flv.len() { - break; - } - tags.push(ParsedTag { - tag_type, - timestamp, - body: flv[body_start..body_start + size].to_vec(), - }); - off = body_start + size + 4; - } - tags -} - -/// A frame's tag timestamp must survive the round trip (PTS in milliseconds). -#[tokio::test(start_paused = true)] -async fn export_preserves_timestamps() { - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); - let consumer = producer.consume(); - let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - - let mut importer = Import::new(producer, catalog.clone()); - importer - .decode(&mut bytes::BytesMut::from(synth_flv().as_slice())) - .unwrap(); - catalog.finish().unwrap(); - - let exporter = Export::new(consumer).unwrap(); - let exported = drain_export(exporter, importer).await; - - let tags = parse_tags(&exported); - let video_ts: Vec = tags - .iter() - .filter(|t| t.tag_type == super::TAG_VIDEO && t.body[1] == super::AVC_NALU) - .map(|t| t.timestamp) - .collect(); - assert_eq!(video_ts, vec![0, 33]); -} - /// A real VP9 key frame (profile 0, 320x240) from the VP9 parser's test vector. const VP9_KEYFRAME: &[u8] = &[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]; @@ -260,7 +199,7 @@ fn synth_enhanced_flv() -> Vec { let mut out = Vec::new(); out.extend_from_slice(b"FLV"); out.push(1); - out.push(0x05); // audio | video + out.push(0x05); out.extend_from_slice(&9u32.to_be_bytes()); out.extend_from_slice(&0u32.to_be_bytes()); @@ -280,7 +219,7 @@ fn synth_enhanced_flv() -> Vec { let mut a0 = vec![(super::AUDIO_FORMAT_EX << 4) | super::AUDIO_PACKET_CODED_FRAMES]; a0.extend_from_slice(b"Opus"); a0.extend_from_slice(&[0xfc, 0xff, 0xfe]); - write_tag(&mut out, super::TAG_AUDIO, 0, &a0); + write_tag(&mut out, super::TAG_AUDIO, 20, &a0); out } @@ -289,21 +228,20 @@ fn synth_enhanced_flv() -> Vec { /// round trip as enhanced-RTMP FourCC payloads. #[tokio::test(start_paused = true)] async fn export_roundtrips_enhanced() { - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); + let mut producer = moq_net::Broadcast::new().produce(); let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = Import::new(producer, catalog.clone()); importer - .decode(&mut bytes::BytesMut::from(synth_enhanced_flv().as_slice())) + .decode(&bytes::BytesMut::from(synth_enhanced_flv().as_slice())) .unwrap(); catalog.finish().unwrap(); let exporter = Export::new(consumer).unwrap(); let exported = drain_export(exporter, importer).await; - let tags = parse_tags(&exported); + let tags = parse_tags(&exported); // The video frame is an enhanced (FourCC) vp09 tag. assert!( tags.iter().any(|t| t.tag_type == super::TAG_VIDEO @@ -311,20 +249,19 @@ async fn export_roundtrips_enhanced() { && &t.body[1..5] == b"vp09"), "expected an enhanced vp09 video tag" ); - // The audio carries an enhanced Opus sequence header (not a coded-frame tag). + // The audio carries an enhanced Opus sequence header. assert!( tags.iter().any(|t| t.tag_type == super::TAG_AUDIO - && t.body[0] >> 4 == super::AUDIO_FORMAT_EX - && t.body[0] & 0x0f == super::AUDIO_PACKET_SEQUENCE_START + && (t.body[0] >> 4) == super::AUDIO_FORMAT_EX && &t.body[1..5] == b"Opus"), - "expected an enhanced Opus sequence-header tag" + "expected an enhanced Opus audio tag" ); - // Re-import and confirm the codecs survive. + // Re-import the exported bytes and confirm the codecs rebuild. let mut bcast2 = moq_net::Broadcast::new().produce(); let cat2 = crate::catalog::Producer::new(&mut bcast2).unwrap(); let mut imp2 = Import::new(bcast2, cat2.clone()); - imp2.decode(&mut bytes::BytesMut::from(exported.as_slice())).unwrap(); + imp2.decode(&bytes::BytesMut::from(exported.as_slice())).unwrap(); imp2.finish().unwrap(); let snap = cat2.snapshot(); @@ -337,3 +274,55 @@ async fn export_roundtrips_enhanced() { AudioCodec::Opus )); } + +struct ParsedTag { + tag_type: u8, + timestamp: u32, + body: Vec, +} + +/// Parse an FLV byte stream into its tags (skipping the 9-byte file header and +/// every `PreviousTagSize`). +fn parse_tags(flv: &[u8]) -> Vec { + let mut tags = Vec::new(); + let mut off = 9 + 4; // file header + PreviousTagSize0 + while off + 11 <= flv.len() { + let tag_type = flv[off]; + let size = super::read_u24(&flv[off + 1..off + 4]) as usize; + let timestamp = super::read_u24(&flv[off + 4..off + 7]) | ((flv[off + 7] as u32) << 24); + let body_start = off + 11; + if body_start + size + 4 > flv.len() { + break; + } + tags.push(ParsedTag { + tag_type, + timestamp, + body: flv[body_start..body_start + size].to_vec(), + }); + off = body_start + size + 4; + } + tags +} + +/// A frame's tag timestamp must survive the round trip (PTS in milliseconds). +#[tokio::test(start_paused = true)] +async fn export_preserves_timestamps() { + let mut producer = moq_net::Broadcast::new().produce(); + let consumer = producer.consume(); + let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + + let mut importer = Import::new(producer, catalog.clone()); + importer.decode(&bytes::BytesMut::from(synth_flv().as_slice())).unwrap(); + catalog.finish().unwrap(); + + let exporter = Export::new(consumer).unwrap(); + let exported = drain_export(exporter, importer).await; + + let tags = parse_tags(&exported); + let video_ts: Vec = tags + .iter() + .filter(|t| t.tag_type == super::TAG_VIDEO && t.body[1] == super::AVC_NALU) + .map(|t| t.timestamp) + .collect(); + assert_eq!(video_ts, vec![0, 33]); +} diff --git a/rs/moq-mux/src/container/flv/import.rs b/rs/moq-mux/src/container/flv/import.rs index f0b3ccfdb..5ac262b2c 100644 --- a/rs/moq-mux/src/container/flv/import.rs +++ b/rs/moq-mux/src/container/flv/import.rs @@ -20,7 +20,6 @@ use bytes::{Buf, Bytes, BytesMut}; use hang::catalog::{AAC, AudioCodec, AudioConfig, Container, H264, VideoConfig}; -use tokio::io::{AsyncRead, AsyncReadExt}; use super::{ AAC_RAW, AAC_SEQUENCE_HEADER, AUDIO_FORMAT_AAC, AUDIO_FORMAT_EX, AUDIO_PACKET_CODED_FRAMES, @@ -42,9 +41,9 @@ const MAX_DATA_OFFSET: usize = 64 * 1024; /// `ffmpeg -f flv`. Unsupported codecs, plus `onMetaData` script tags, are logged /// and dropped. A single FLV stream carries at most one video and one audio /// track; a new sequence header replaces the previous configuration. -pub struct Import { +pub struct Import { broadcast: moq_net::BroadcastProducer, - catalog: crate::catalog::Producer, + catalog: crate::catalog::Producer, /// Accumulated unparsed input. Whole tags are drained out; a trailing partial /// tag is retained for the next [`decode`](Self::decode) call. @@ -69,9 +68,9 @@ struct AudioStream { config: AudioConfig, } -impl Import { +impl Import { /// Create a demuxer publishing into `broadcast` with renditions announced on `catalog`. - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { + pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { Self { broadcast, catalog, @@ -82,35 +81,11 @@ impl Import { } } - /// True once at least one stream's sequence header has been parsed. - pub fn is_initialized(&self) -> bool { - self.video.is_some() || self.audio.is_some() - } - - /// Decode from an asynchronous reader, driving [`Self::decode`] in a loop. - pub async fn decode_from(&mut self, reader: &mut T) -> anyhow::Result<()> { - let mut chunk = BytesMut::with_capacity(64 * 1024); - loop { - chunk.clear(); - let n = reader.read_buf(&mut chunk).await?; - if n == 0 { - break; - } - self.decode(&mut chunk)?; - } - Ok(()) - } - /// Append `buf` to the internal scratch and demux every whole tag it now /// completes. The buffer is fully consumed; a trailing partial tag is retained /// for the next call. - pub fn decode>(&mut self, buf: &mut T) -> anyhow::Result<()> { - while buf.has_remaining() { - let chunk = buf.chunk(); - self.buffer.extend_from_slice(chunk); - let len = chunk.len(); - buf.advance(len); - } + pub fn decode(&mut self, data: &[u8]) -> anyhow::Result<()> { + self.buffer.extend_from_slice(data); self.drain() } @@ -236,12 +211,7 @@ impl Import { match crate::codec::vp9::config_from_keyframe(data) { Ok(Some(config)) => self.init_video(config)?, Ok(None) => {} - Err(err) => { - // The header didn't parse, so the frame is unusable: drop it - // rather than forwarding a frame we couldn't validate. - tracing::warn!(%err, "dropping malformed VP9 key frame"); - return Ok(()); - } + Err(err) => tracing::warn!(%err, "dropping malformed VP9 key frame"), } } @@ -323,9 +293,8 @@ impl Import { } } - /// Write one decoded video sample. A leading delta before the first keyframe - /// (a mid-GOP join) is tolerated by the lenient-start producer rather than - /// aborting. + /// Write one decoded video sample, dropping a leading delta before the first + /// keyframe (a mid-GOP join) rather than aborting. fn write_video(&mut self, data: &[u8], dts: u64, composition_time: i32, keyframe: bool) -> anyhow::Result<()> { let Some(stream) = self.video.as_mut() else { tracing::debug!("video frame before sequence header, dropping"); @@ -334,12 +303,15 @@ impl Import { // FLV stores DTS in the tag; PTS is DTS plus the composition offset. let pts_ms = (dts as i64) + (composition_time as i64); anyhow::ensure!(pts_ms >= 0, "negative video presentation timestamp"); - stream.track.write(Frame { + match stream.track.write(Frame { timestamp: Timestamp::from_millis(pts_ms as u64)?, + duration: None, payload: Bytes::copy_from_slice(data), keyframe, - })?; - Ok(()) + }) { + Ok(()) | Err(crate::Error::MissingKeyframe(_)) => Ok(()), + Err(e) => Err(e.into()), + } } /// Write one audio frame as its own group, so the relay can forward it immediately. @@ -350,6 +322,7 @@ impl Import { }; stream.track.write(Frame { timestamp: Timestamp::from_millis(timestamp)?, + duration: None, payload: Bytes::copy_from_slice(data), keyframe: true, })?; @@ -368,11 +341,11 @@ impl Import { .lock() .video .renditions - .insert(net_track.name.clone(), config.clone()); + .insert(net_track.name().to_string(), config.clone()); self.video = Some(VideoStream { - // Live FLV can join mid-GOP; tolerate leading deltas before the first keyframe. - track: crate::container::Producer::new(net_track, crate::catalog::hang::Container::Legacy) - .with_lenient_start(), + // Leading deltas before the first keyframe are skipped at the write + // site (the producer reports MissingKeyframe), so a mid-GOP join works. + track: crate::container::Producer::new(net_track, crate::catalog::hang::Container::Legacy), config, }); Ok(()) @@ -389,7 +362,7 @@ impl Import { .lock() .audio .renditions - .insert(net_track.name.clone(), config.clone()); + .insert(net_track.name().to_string(), config.clone()); self.audio = Some(AudioStream { track: crate::container::Producer::new(net_track, crate::catalog::hang::Container::Legacy), config, @@ -402,7 +375,7 @@ impl Import { fn replace_video(&mut self) -> anyhow::Result { if let Some(mut old) = self.video.take() { old.track.finish()?; - self.catalog.lock().video.renditions.remove(&old.track.name); + self.catalog.lock().video.renditions.remove(old.track.name()); } Ok(self.broadcast.unique_track(".flv-v")?) } @@ -412,7 +385,7 @@ impl Import { fn replace_audio(&mut self) -> anyhow::Result { if let Some(mut old) = self.audio.take() { old.track.finish()?; - self.catalog.lock().audio.renditions.remove(&old.track.name); + self.catalog.lock().audio.renditions.remove(old.track.name()); } Ok(self.broadcast.unique_track(".flv-a")?) } @@ -440,14 +413,14 @@ impl Import { } } -impl Drop for Import { +impl Drop for Import { fn drop(&mut self) { let mut catalog = self.catalog.lock(); if let Some(stream) = &self.video { - catalog.video.renditions.remove(&stream.track.name); + catalog.video.renditions.remove(stream.track.name()); } if let Some(stream) = &self.audio { - catalog.audio.renditions.remove(&stream.track.name); + catalog.audio.renditions.remove(stream.track.name()); } } } diff --git a/rs/moq-mux/src/container/flv/import_test.rs b/rs/moq-mux/src/container/flv/import_test.rs index 4f495f210..252e0b7b0 100644 --- a/rs/moq-mux/src/container/flv/import_test.rs +++ b/rs/moq-mux/src/container/flv/import_test.rs @@ -75,13 +75,12 @@ fn synth_flv() -> Vec { #[tokio::test(start_paused = true)] async fn import_populates_catalog() { - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); + let mut producer = moq_net::Broadcast::new().produce(); let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = Import::new(producer, catalog.clone()); - let mut buf = bytes::BytesMut::from(synth_flv().as_slice()); - importer.decode(&mut buf).unwrap(); + let buf = bytes::BytesMut::from(synth_flv().as_slice()); + importer.decode(&buf).unwrap(); importer.finish().unwrap(); let snap = catalog.snapshot(); @@ -101,21 +100,22 @@ async fn import_populates_catalog() { #[tokio::test(start_paused = true)] async fn import_emits_frames() { - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); + let mut producer = moq_net::Broadcast::new().produce(); let consumer = producer.consume(); let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = Import::new(producer, catalog.clone()); - let mut buf = bytes::BytesMut::from(synth_flv().as_slice()); - importer.decode(&mut buf).unwrap(); + let buf = bytes::BytesMut::from(synth_flv().as_slice()); + importer.decode(&buf).unwrap(); importer.finish().unwrap(); let snap = catalog.snapshot(); let video_name = snap.video.renditions.keys().next().unwrap().clone(); // Decode the video track back through the Legacy container. - let track = consumer.subscribe_track(&moq_net::Track::new(video_name)).unwrap(); + let track = consumer + .subscribe_track(&moq_net::Track::new(video_name.as_str())) + .unwrap(); let mut decoder = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy) .with_latency(std::time::Duration::from_secs(1)); let frame = decoder.read().await.unwrap().expect("a video frame"); @@ -132,13 +132,12 @@ async fn import_handles_split_input() { let flv = synth_flv(); let (head, tail) = flv.split_at(flv.len() / 2); - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); + let mut producer = moq_net::Broadcast::new().produce(); let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = Import::new(producer, catalog.clone()); - importer.decode(&mut bytes::BytesMut::from(head)).unwrap(); - importer.decode(&mut bytes::BytesMut::from(tail)).unwrap(); + importer.decode(&bytes::BytesMut::from(head)).unwrap(); + importer.decode(&bytes::BytesMut::from(tail)).unwrap(); importer.finish().unwrap(); let snap = catalog.snapshot(); @@ -146,8 +145,9 @@ async fn import_handles_split_input() { assert_eq!(snap.audio.renditions.len(), 1); } -/// A real VP9 key frame (profile 0, 320x240) from the VP9 parser's test vector. -const VP9_KEYFRAME: &[u8] = &[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]; +/// A real VP9 key frame (profile 0, 320x240), borrowed from the VP9 parser's +/// own test vector. Bytes after the frame size are irrelevant to the header. +const VP9_KEYFRAME_320X240: &[u8] = &[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]; /// Enhanced-RTMP (FourCC) VP9 video configures from the key frame and emits it. #[tokio::test(start_paused = true)] @@ -163,15 +163,13 @@ async fn import_enhanced_vp9() { let first = super::VIDEO_EX_HEADER | (super::FRAME_TYPE_KEY << 4) | super::VIDEO_PACKET_CODED_FRAMES; let mut body = vec![first]; body.extend_from_slice(b"vp09"); - body.extend_from_slice(VP9_KEYFRAME); + body.extend_from_slice(VP9_KEYFRAME_320X240); write_tag(&mut out, super::TAG_VIDEO, 0, &body); - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); + let mut producer = moq_net::Broadcast::new().produce(); let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let mut importer = Import::new(producer, catalog.clone()); - importer.decode(&mut bytes::BytesMut::from(out.as_slice())).unwrap(); + importer.decode(&bytes::BytesMut::from(out.as_slice())).unwrap(); importer.finish().unwrap(); let snap = catalog.snapshot(); @@ -211,12 +209,10 @@ async fn import_enhanced_opus() { frame.extend_from_slice(&[0xfc, 0xff, 0xfe]); write_tag(&mut out, super::TAG_AUDIO, 20, &frame); - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); + let mut producer = moq_net::Broadcast::new().produce(); let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let mut importer = Import::new(producer, catalog.clone()); - importer.decode(&mut bytes::BytesMut::from(out.as_slice())).unwrap(); + importer.decode(&bytes::BytesMut::from(out.as_slice())).unwrap(); importer.finish().unwrap(); let snap = catalog.snapshot(); @@ -230,11 +226,10 @@ async fn import_enhanced_opus() { #[tokio::test(start_paused = true)] async fn import_rejects_non_flv() { - let broadcast = moq_net::Broadcast::new(); - let mut producer = broadcast.produce(); + let mut producer = moq_net::Broadcast::new().produce(); let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = Import::new(producer, catalog); - let mut buf = bytes::BytesMut::from(&b"NOTFLV\x00\x00\x00"[..]); - assert!(importer.decode(&mut buf).is_err()); + let buf = bytes::BytesMut::from(&b"NOTFLV\x00\x00\x00"[..]); + assert!(importer.decode(&buf).is_err()); } diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index 173586dbd..081f90006 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -2,15 +2,15 @@ use std::collections::HashMap; use std::task::Poll; use std::time::Duration; -use anyhow::Context; use bytes::Bytes; use hang::catalog::{Catalog, Container, VideoConfig}; use mp4_atom::{DecodeMaybe, Encode}; -use crate::catalog::CatalogFormat; +use crate::Result; +use crate::catalog::Stream; +use crate::container::ExportSource; use crate::container::Frame; - -use crate::container::{CatalogSource, ExportSource}; +use crate::container::fmp4::Error; /// Subscribe to a moq broadcast and produce a single fMP4 / CMAF byte stream. /// @@ -26,9 +26,16 @@ use crate::container::{CatalogSource, ExportSource}; /// keyframes); [`with_fragment_duration`](Self::with_fragment_duration) caps the /// fragment duration for downstream consumers that throttle by fragment rate. /// Returns `None` when the broadcast ends. -pub struct Export { +/// +/// [`next_fragment`](Self::next_fragment) returns the same bytes wrapped in a +/// [`Fragment`] that also carries whether the chunk is the init segment, whether +/// a media fragment begins at a sync sample, and its presentation duration. A +/// segmenting consumer (e.g. an HLS/LL-HLS packager) needs that to map fragments +/// onto segments and parts; narrow the catalog to a single rendition with +/// [`catalog::Filter`](crate::catalog::Filter) so the fragments belong to one track. +pub struct Export { broadcast: moq_net::BroadcastConsumer, - catalog: Option, + catalog: Option, latency: Duration, fragment_duration: Option, @@ -43,6 +50,25 @@ pub struct Export { init_emitted: bool, } +/// One emitted CMAF chunk: either the init segment or a moof+mdat fragment, +/// with the metadata a segmenting consumer needs. +#[derive(Clone, Debug)] +pub struct Fragment { + /// The encoded bytes: ftyp+moov for the init, otherwise one moof+mdat. + pub data: Bytes, + + /// True only for the first emit (the init segment). + pub init: bool, + + /// A media fragment that begins at a sync sample, so it can start a segment. + /// Video fragments are independent only at a GOP boundary (keyframe); audio + /// fragments are always independent. Always false for the init segment. + pub independent: bool, + + /// Presentation duration of the fragment in seconds (0 for the init segment). + pub duration: f64, +} + struct Fmp4Track { source: ExportSource, @@ -53,9 +79,17 @@ struct Fmp4Track { /// moof+mdat on the next keyframe (video) or duration cap. buffer: Vec, + /// Whether the first frame of the current `buffer` was a keyframe, i.e. the + /// fragment it produces can start an HLS segment. Meaningless for audio. + buffer_independent: bool, + /// True if this track is video. Video tracks roll fragments on keyframes. is_video: bool, + /// Fallback duration for a trailing frame that carries no per-sample duration + /// (Legacy / LOC sources). Derived from the catalog framerate / sample rate. + default_frame: Duration, + /// Whether the source has signalled end-of-track. finished: bool, @@ -64,29 +98,16 @@ struct Fmp4Track { sequence_number: u32, } -impl Export { - /// Subscribe to `broadcast` and produce fMP4 byte chunks, using the default - /// catalog format ([`CatalogFormat::Hang`]). - /// - /// Use [`with_catalog_format`](Self::with_catalog_format) to subscribe to a - /// non-default catalog track (e.g. MSF). - pub fn new(broadcast: moq_net::BroadcastConsumer) -> Result { - Self::with_catalog_format(broadcast, CatalogFormat::default()) - } - - /// Subscribe to `broadcast` and produce fMP4 byte chunks, selecting an - /// explicit `catalog_format` for track discovery. +impl Export { + /// Subscribe to `broadcast` and produce fMP4 byte chunks, driving track + /// (un)subscription from `catalog`. /// - /// Both formats drive the same internal `hang::Catalog`-based pipeline (MSF - /// snapshots are converted on receipt), so the only observable difference - /// is which wire catalog track is consumed. - pub fn with_catalog_format( - broadcast: moq_net::BroadcastConsumer, - catalog_format: CatalogFormat, - ) -> Result { - let catalog = CatalogSource::new(&broadcast, catalog_format)?; - - Ok(Self { + /// `catalog` is any [`Stream`] of catalog snapshots, typically a + /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or wrapped in + /// [`catalog::Filter`](crate::catalog::Filter) / + /// [`catalog::Target`](crate::catalog::Target) to narrow the rendition set. + pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { + Self { broadcast, catalog: Some(catalog), latency: Duration::ZERO, @@ -94,7 +115,7 @@ impl Export { tracks: HashMap::new(), catalog_snapshot: None, init_emitted: false, - }) + } } /// Set the maximum buffering latency for each per-track source. @@ -128,12 +149,23 @@ impl Export { /// subsequent call returns one moof+mdat fragment. Fragments arrive in ascending /// timestamp order across tracks. Returns `None` when the catalog and every track /// have ended. - pub async fn next(&mut self) -> anyhow::Result> { - kio::wait(|waiter| self.poll_next(waiter)).await + pub async fn next(&mut self) -> Result> { + Ok(self.next_fragment().await?.map(|f| f.data)) } /// Poll-based variant of [`Self::next`]. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + Poll::Ready(Ok(std::task::ready!(self.poll_next_fragment(waiter)?).map(|f| f.data))) + } + + /// Like [`next`](Self::next) but returns a [`Fragment`] carrying segment metadata + /// (init flag, sync-sample independence, presentation duration). + pub async fn next_fragment(&mut self) -> Result> { + kio::wait(|waiter| self.poll_next_fragment(waiter)).await + } + + /// Poll-based variant of [`Self::next_fragment`]. + pub fn poll_next_fragment(&mut self, waiter: &kio::Waiter) -> Poll>> { // 1. Drain catalog updates and (un)subscribe tracks accordingly. while let Some(catalog) = self.catalog.as_mut() { match catalog.poll_next(waiter)? { @@ -184,7 +216,12 @@ impl Export { if self.init_ready() { let init = self.build_init()?; self.init_emitted = true; - return Poll::Ready(Ok(Some(init))); + return Poll::Ready(Ok(Some(Fragment { + data: init, + init: true, + independent: false, + duration: 0.0, + }))); } // Still waiting for codec configs. If every track is finished and // the init still isn't buildable, the source ended before producing @@ -212,13 +249,18 @@ impl Export { let flush_before = should_flush(track, &frame, frag, has_video_track); if flush_before { let frames = std::mem::take(&mut track.buffer); - let emit = encode_fragment(track, frames)?; + let fragment = emit_fragment(track, frames)?; + // The flushed run is done; the incoming frame opens the next buffer. + track.buffer_independent = frame.keyframe; track.buffer.push(frame); - return Poll::Ready(Ok(Some(emit))); + return Poll::Ready(Ok(Some(fragment))); + } + if track.buffer.is_empty() { + track.buffer_independent = frame.keyframe; } track.buffer.push(frame); // Frame appended to buffer; loop again to look for more work or a flush. - return self.poll_next(waiter); + return self.poll_next_fragment(waiter); } // 5. No pending frames. Flush any finished tracks' remaining buffers, @@ -239,8 +281,8 @@ impl Export { if let Some(name) = flushable { let track = self.tracks.get_mut(&name).unwrap(); let frames = std::mem::take(&mut track.buffer); - let emit = encode_fragment(track, frames)?; - return Poll::Ready(Ok(Some(emit))); + let fragment = emit_fragment(track, frames)?; + return Poll::Ready(Ok(Some(fragment))); } // 6. If catalog is closed and every track is finished and drained, we're done. @@ -255,7 +297,7 @@ impl Export { Poll::Pending } - fn update_catalog(&mut self, catalog: &Catalog) -> anyhow::Result<()> { + fn update_catalog(&mut self, catalog: &Catalog) -> Result<()> { let mut active: HashMap = HashMap::new(); for name in catalog.video.renditions.keys() { active.insert(name.clone(), ()); @@ -274,13 +316,21 @@ impl Export { } let source = ExportSource::for_video(&self.broadcast, name, config, self.latency)?; let timescale = catalog_timescale_video(config); + // A zero / NaN / infinite framerate would make `1.0 / fps` non-finite and panic + // `Duration::from_secs_f64`; fall back to the default in that case. + let framerate = config + .framerate + .filter(|fps| fps.is_finite() && *fps > 0.0) + .unwrap_or(30.0); self.tracks.insert( name.clone(), Fmp4Track { source, pending: None, buffer: Vec::new(), + buffer_independent: false, is_video: true, + default_frame: Duration::from_secs_f64(1.0 / framerate), finished: false, track_id: next_track_id, timescale, @@ -302,7 +352,10 @@ impl Export { source, pending: None, buffer: Vec::new(), + buffer_independent: false, is_video: false, + // Fallback for a duration-less trailing sample (~1024 samples/frame). + default_frame: Duration::from_secs_f64(1024.0 / config.sample_rate.max(1) as f64), finished: false, track_id: next_track_id, timescale, @@ -328,15 +381,18 @@ impl Export { /// Build the merged ftyp + multi-track moov init segment from the cached /// catalog snapshot. CMAF tracks pass their existing init segment through; /// Legacy tracks synthesize a `trak` from codec config + dimensions. - fn build_init(&self) -> anyhow::Result { - let catalog = self.catalog_snapshot.as_ref().context("no catalog snapshot")?; + fn build_init(&self) -> Result { + let catalog = self.catalog_snapshot.as_ref().ok_or(Error::NoCatalogSnapshot)?; let mut traks: Vec = Vec::new(); let mut trexs: Vec = Vec::new(); let mut ftyp_data: Option = None; for (name, config) in &catalog.video.renditions { - let track = self.tracks.get(name).context("video track not subscribed")?; + let track = self + .tracks + .get(name) + .ok_or_else(|| Error::MissingVideoTrack(name.clone()))?; match &config.container { Container::Cmaf { init, .. } => { extract_init(init, &mut ftyp_data, &mut traks, &mut trexs)?; @@ -361,7 +417,10 @@ impl Export { } for (name, config) in &catalog.audio.renditions { - let track = self.tracks.get(name).context("audio track not subscribed")?; + let track = self + .tracks + .get(name) + .ok_or_else(|| Error::MissingAudioTrack(name.clone()))?; match &config.container { Container::Cmaf { init, .. } => { extract_init(init, &mut ftyp_data, &mut traks, &mut trexs)?; @@ -417,7 +476,7 @@ fn extract_init( ftyp_data: &mut Option, traks: &mut Vec, trexs: &mut Vec, -) -> anyhow::Result<()> { +) -> Result<()> { let mut cursor = std::io::Cursor::new(init.as_ref()); while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? { match atom { @@ -458,9 +517,21 @@ fn should_flush(track: &Fmp4Track, frame: &Frame, fragment_duration: Option true, Some(d) => { - let first = track.buffer.first().unwrap(); - let delta_us = frame.timestamp.as_micros().saturating_sub(first.timestamp.as_micros()); - delta_us >= d.as_micros() + // Frames within a track are in *decode* order; B-frames have + // non-monotonic PTS, so the span of the buffer is min..max of all + // PTS, not just first..incoming. + let mut min = Duration::from(frame.timestamp); + let mut max = min; + for f in &track.buffer { + let pts = Duration::from(f.timestamp); + if pts < min { + min = pts; + } + if pts > max { + max = pts; + } + } + max.saturating_sub(min) >= d } // No video keyframe will ever arrive to roll the fragment, so for // audio-only broadcasts in `None` mode we fall back to per-frame @@ -470,8 +541,10 @@ fn should_flush(track: &Fmp4Track, frame: &Frame, fragment_duration: Option) -> anyhow::Result { - anyhow::ensure!(!frames.is_empty(), "encode_fragment called with no frames"); +fn encode_fragment(track: &mut Fmp4Track, frames: Vec) -> Result { + if frames.is_empty() { + return Err(Error::NoFrames.into()); + } let seq = track.sequence_number; track.sequence_number += 1; Ok(crate::container::fmp4::encode_fragment( @@ -482,6 +555,48 @@ fn encode_fragment(track: &mut Fmp4Track, frames: Vec) -> anyhow::Result< )?) } +/// Encode a buffered run and wrap it with the metadata a segmenting consumer needs. +fn emit_fragment(track: &mut Fmp4Track, frames: Vec) -> Result { + // Audio has no keyframes, so every audio fragment is independent; video is + // independent only when its buffer opened on a keyframe (a GOP boundary). + let independent = !track.is_video || track.buffer_independent; + let duration = fragment_seconds(&frames, track.default_frame); + let data = encode_fragment(track, frames)?; + Ok(Fragment { + data, + init: false, + independent, + duration, + }) +} + +/// Presentation duration of a fragment, in seconds. +/// +/// When every sample carries a duration (the CMAF case) the per-sample durations +/// tile the timeline, so their sum is exact. Legacy / LOC sources carry none, so +/// fall back to the presentation span plus one `default_frame` for the trailing +/// sample (which has no successor to bound it). +fn fragment_seconds(frames: &[Frame], default_frame: Duration) -> f64 { + if frames.is_empty() { + return 0.0; + } + if frames.iter().all(|f| f.duration.is_some()) { + return frames + .iter() + .map(|f| Duration::from(f.duration.unwrap())) + .sum::() + .as_secs_f64(); + } + let mut min = Duration::MAX; + let mut max = Duration::ZERO; + for f in frames { + let pts = Duration::from(f.timestamp); + min = min.min(pts); + max = max.max(pts); + } + ((max - min) + default_frame).as_secs_f64() +} + fn catalog_timescale_video(config: &VideoConfig) -> u64 { match &config.container { Container::Cmaf { init, .. } => { @@ -498,13 +613,13 @@ fn catalog_timescale_audio(config: &hang::catalog::AudioConfig) -> u64 { } } -fn parse_timescale_from_init(init: &[u8]) -> anyhow::Result { +fn parse_timescale_from_init(init: &[u8]) -> Result { let mut cursor = std::io::Cursor::new(init); while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? { if let mp4_atom::Any::Moov(moov) = atom { - let trak = moov.trak.first().context("no tracks in moov")?; + let trak = moov.trak.first().ok_or(Error::NoTracks)?; return Ok(trak.mdia.mdhd.timescale as u64); } } - anyhow::bail!("no moov in init data") + Err(Error::NoMoov.into()) } diff --git a/rs/moq-mux/src/container/fmp4/export_test.rs b/rs/moq-mux/src/container/fmp4/export_test.rs index 578974c09..29810d0ed 100644 --- a/rs/moq-mux/src/container/fmp4/export_test.rs +++ b/rs/moq-mux/src/container/fmp4/export_test.rs @@ -24,7 +24,9 @@ async fn avc3_source_to_cmaf_export_roundtrip() { let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let track = producer.unique_track(".avc3").unwrap(); + let track = producer + .create_track(moq_net::Track::new(producer.unique_name(".avc3"))) + .unwrap(); let mut config = VideoConfig::new(H264 { profile: 0x42, constraints: 0xc0, @@ -34,7 +36,7 @@ async fn avc3_source_to_cmaf_export_roundtrip() { config.coded_width = Some(320); config.coded_height = Some(240); config.container = Container::Legacy; - catalog.lock().video.renditions.insert(track.name.clone(), config); + catalog.lock().video.renditions.insert(track.name().to_string(), config); const SC: &[u8] = &[0, 0, 0, 1]; let sps = &[0x67u8, 0x42, 0xc0, 0x1f, 0xde, 0xad, 0xbe, 0xef][..]; @@ -54,11 +56,14 @@ async fn avc3_source_to_cmaf_export_roundtrip() { timestamp: Timestamp::from_micros(0).unwrap(), payload: keyframe_payload, keyframe: true, + duration: None, }) .unwrap(); track_producer.finish().unwrap(); - let mut exporter = crate::container::fmp4::Export::new(consumer).expect("new Fmp4"); + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = crate::container::fmp4::Export::new(consumer, catalog_stream); let init = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) .await @@ -119,7 +124,9 @@ async fn legacy_aac_source_to_cmaf_export_synthesizes_esds() { let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let track = producer.unique_track(".aac").unwrap(); + let track = producer + .create_track(moq_net::Track::new(producer.unique_name(".aac"))) + .unwrap(); // AAC-LC (profile 2), 44100 Hz, stereo. The TS importer sets `description` // via aac::Config::encode; mirror that here. @@ -132,19 +139,21 @@ async fn legacy_aac_source_to_cmaf_export_synthesizes_esds() { let mut config = AudioConfig::new(AAC { profile: 2 }, 44100, 2); config.description = Some(description.clone()); config.container = Container::Legacy; - catalog.lock().audio.renditions.insert(track.name.clone(), config); + catalog.lock().audio.renditions.insert(track.name().to_string(), config); let mut track_producer = crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy); track_producer .write(crate::container::Frame { timestamp: Timestamp::from_micros(0).unwrap(), + duration: None, payload: Bytes::from_static(&[0x01, 0x02, 0x03, 0x04]), keyframe: true, }) .unwrap(); track_producer.finish().unwrap(); - let mut exporter = crate::container::fmp4::Export::new(consumer).expect("new Fmp4"); + let catalog_stream = crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).unwrap(); + let mut exporter = crate::container::fmp4::Export::new(consumer, catalog_stream); let init = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) .await @@ -205,12 +214,14 @@ async fn vp8_source_to_cmaf_export_synthesizes_vp08() { let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let track = producer.unique_track(".vp8").unwrap(); + let track = producer + .create_track(moq_net::Track::new(producer.unique_name(".vp8"))) + .unwrap(); let mut config = VideoConfig::new(VideoCodec::VP8); config.coded_width = Some(320); config.coded_height = Some(240); config.container = Container::Legacy; - catalog.lock().video.renditions.insert(track.name.clone(), config); + catalog.lock().video.renditions.insert(track.name().to_string(), config); let mut track_producer = crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy); track_producer @@ -218,11 +229,14 @@ async fn vp8_source_to_cmaf_export_synthesizes_vp08() { timestamp: Timestamp::from_micros(0).unwrap(), payload: Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a]), keyframe: true, + duration: None, }) .unwrap(); track_producer.finish().unwrap(); - let mut exporter = crate::container::fmp4::Export::new(consumer).expect("new Fmp4"); + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = crate::container::fmp4::Export::new(consumer, catalog_stream); let init = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) .await @@ -274,7 +288,9 @@ async fn vp9_source_to_cmaf_export_synthesizes_vp09() { let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let track = producer.unique_track(".vp9").unwrap(); + let track = producer + .create_track(moq_net::Track::new(producer.unique_name(".vp9"))) + .unwrap(); let mut config = VideoConfig::new(VP9 { profile: 0, level: 20, @@ -288,7 +304,7 @@ async fn vp9_source_to_cmaf_export_synthesizes_vp09() { config.coded_width = Some(320); config.coded_height = Some(240); config.container = Container::Legacy; - catalog.lock().video.renditions.insert(track.name.clone(), config); + catalog.lock().video.renditions.insert(track.name().to_string(), config); let mut track_producer = crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy); track_producer @@ -296,11 +312,14 @@ async fn vp9_source_to_cmaf_export_synthesizes_vp09() { timestamp: Timestamp::from_micros(0).unwrap(), payload: Bytes::from_static(&[0x82, 0x49, 0x83, 0x42]), keyframe: true, + duration: None, }) .unwrap(); track_producer.finish().unwrap(); - let mut exporter = crate::container::fmp4::Export::new(consumer).expect("new Fmp4"); + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = crate::container::fmp4::Export::new(consumer, catalog_stream); let init = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) .await @@ -339,6 +358,105 @@ async fn vp9_source_to_cmaf_export_synthesizes_vp09() { moov.encode(&mut buf).expect("encode synthesized moov"); } +/// AV1 source (catalog `Container::Legacy`, codec `av01`, no `description`) → +/// fMP4 export must synthesize an `av01` sample entry whose `av1C` round-trips +/// the catalog's AV1 parameters. AV1 publishes its sequence header in-band +/// (like `hev1`/`avc3`), so there is no out-of-band config and `config_obus` +/// stays empty. +#[tokio::test(start_paused = true)] +async fn av1_source_to_cmaf_export_synthesizes_av01() { + use crate::container::Timestamp; + use bytes::Bytes; + use hang::catalog::{AV1, Container, VideoConfig}; + + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let consumer = producer.consume(); + + let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + let track = producer + .create_track(moq_net::Track::new(producer.unique_name(".av01"))) + .unwrap(); + let mut config = VideoConfig::new(AV1 { + profile: 0, + level: 8, + tier: 'M', + bitdepth: 10, + mono_chrome: false, + chroma_subsampling_x: true, + chroma_subsampling_y: true, + chroma_sample_position: 2, + color_primaries: 9, + transfer_characteristics: 16, + matrix_coefficients: 9, + full_range: false, + }); + config.coded_width = Some(320); + config.coded_height = Some(240); + config.container = Container::Legacy; + catalog.lock().video.renditions.insert(track.name().to_string(), config); + + let mut track_producer = crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy); + track_producer + .write(crate::container::Frame { + timestamp: Timestamp::from_micros(0).unwrap(), + payload: Bytes::from_static(&[0x12, 0x00, 0x0a, 0x0b]), + keyframe: true, + duration: None, + }) + .unwrap(); + track_producer.finish().unwrap(); + + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = crate::container::fmp4::Export::new(consumer, catalog_stream); + + let init = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) + .await + .expect("exporter timed out") + .expect("exporter result") + .expect("expected init bytes"); + + drop(track_producer); + drop(catalog); + drop(producer); + + let mut cursor = Cursor::new(init.as_ref()); + let mut moov: Option = None; + while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).expect("decode init") { + if let mp4_atom::Any::Moov(m) = atom { + moov = Some(m); + } + } + let moov = moov.expect("init segment missing moov"); + assert_eq!(moov.trak.len(), 1, "expected single track in moov"); + + let trak = &moov.trak[0]; + let stsd = &trak.mdia.minf.stbl.stsd; + assert_eq!(stsd.codecs.len(), 1, "expected single sample entry"); + let av01 = match &stsd.codecs[0] { + mp4_atom::Codec::Av01(av01) => av01, + other => panic!("expected Av01 sample entry, got {:?}", other), + }; + assert_eq!(av01.visual.width, 320); + assert_eq!(av01.visual.height, 240); + + let av1c = &av01.av1c; + assert_eq!(av1c.seq_profile, 0); + assert_eq!(av1c.seq_level_idx_0, 8); + assert!(!av1c.seq_tier_0, "Main tier"); + assert!(av1c.high_bitdepth, "10-bit"); + assert!(!av1c.twelve_bit); + assert!(av1c.chroma_subsampling_x); + assert!(av1c.chroma_subsampling_y); + assert_eq!(av1c.chroma_sample_position, 2); + assert!(av1c.config_obus.is_empty(), "sequence header stays in-band"); + + // The synthesized init (av1C included) must round-trip through encode. + let mut buf = Vec::new(); + moov.encode(&mut buf).expect("encode synthesized moov"); +} + /// CMAF source (catalog `Container::Cmaf`) → fMP4 export should keep using /// the passthrough init path: existing init bytes are merged into the moov. /// @@ -353,10 +471,12 @@ async fn cmaf_source_to_cmaf_export_passthrough() { let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = crate::container::fmp4::Import::new(producer, catalog); - let mut buf = BytesMut::from(data.as_slice()); - let _ = importer.decode(&mut buf); + let buf = BytesMut::from(data.as_slice()); + let _ = importer.decode(&buf); - let mut exporter = crate::container::fmp4::Export::new(consumer).expect("new Fmp4"); + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = crate::container::fmp4::Export::new(consumer, catalog_stream); let init = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) .await @@ -387,3 +507,107 @@ async fn cmaf_source_to_cmaf_export_passthrough() { let mut buf = Vec::new(); moov.encode(&mut buf).expect("encode merged moov"); } + +/// `next_fragment` reports the init flag, per-fragment sync-sample independence, +/// and a positive duration. With a sub-GOP fragment cap, a part in the middle of +/// a GOP is reported as non-independent while the GOP's leading part stays +/// independent. This is the metadata an HLS/LL-HLS packager consumes. +#[tokio::test(start_paused = true)] +async fn next_fragment_reports_segment_metadata() { + use std::time::Duration; + + use crate::container::Timestamp; + use bytes::BytesMut; + use hang::catalog::{Container, H264, VideoConfig}; + + let broadcast = moq_net::Broadcast::new(); + let mut producer = broadcast.produce(); + let consumer = producer.consume(); + + let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + let track = producer + .create_track(moq_net::Track::new(producer.unique_name(".avc3"))) + .unwrap(); + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0xc0, + level: 0x1f, + inline: true, + }); + config.coded_width = Some(320); + config.coded_height = Some(240); + config.framerate = Some(30.0); + config.container = Container::Legacy; + catalog.lock().video.renditions.insert(track.name().to_string(), config); + + const SC: &[u8] = &[0, 0, 0, 1]; + let sps = &[0x67u8, 0x42, 0xc0, 0x1f, 0xde, 0xad, 0xbe, 0xef][..]; + let pps = &[0x68u8, 0xce, 0x3c, 0x80][..]; + let idr = &[0x65u8, 0x88, 0x84, 0x21, 0x00, 0x11, 0x22, 0x33][..]; + let slice = &[0x41u8, 0x9a, 0x00, 0x01][..]; + + let annexb = |nals: &[&[u8]]| { + let mut buf = BytesMut::new(); + for nal in nals { + buf.extend_from_slice(SC); + buf.extend_from_slice(nal); + } + buf.freeze() + }; + + let frame = |timestamp_us: u64, payload, keyframe| crate::container::Frame { + timestamp: Timestamp::from_micros(timestamp_us).unwrap(), + payload, + keyframe, + duration: None, + }; + + let mut track_producer = crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy); + // GOP 0: keyframe@0 (SPS+PPS+IDR), delta@33ms. GOP 1: keyframe@66ms. + track_producer.write(frame(0, annexb(&[sps, pps, idr]), true)).unwrap(); + track_producer.write(frame(33_000, annexb(&[slice]), false)).unwrap(); + track_producer + .write(frame(66_000, annexb(&[sps, pps, idr]), true)) + .unwrap(); + track_producer.finish().unwrap(); + + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + // Sub-GOP cap so GOP 0 splits into two parts (the trailing part non-independent). + let mut exporter = + crate::container::fmp4::Export::new(consumer, catalog_stream).with_fragment_duration(Duration::from_millis(20)); + + // First emit is the init segment. + let init = tokio::time::timeout(Duration::from_secs(1), exporter.next_fragment()) + .await + .expect("exporter timed out") + .expect("exporter result") + .expect("expected init fragment"); + assert!(init.init, "first fragment must be the init segment"); + assert!(!init.independent); + assert_eq!(init.duration, 0.0); + + // The track is finished, so its three media fragments are all available. Keep + // the broadcast/catalog producers alive (dropping them aborts the consumer); + // the catalog stays open, so the exporter never reaches a clean end — read the + // known fragment count rather than looping to `None`. + let mut independents = Vec::new(); + for _ in 0..3 { + let frag = tokio::time::timeout(Duration::from_secs(1), exporter.next_fragment()) + .await + .expect("exporter timed out") + .expect("exporter result") + .expect("expected a media fragment"); + assert!(!frag.init); + assert!(frag.duration > 0.0, "media fragment duration should be positive"); + independents.push(frag.independent); + } + + // GOP 0 leading part (independent), GOP 0 trailing part (dependent), + // GOP 1 leading part (independent). + assert_eq!(independents, vec![true, false, true]); + + drop(track_producer); + drop(catalog); + drop(producer); +} diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index d90c1e6cb..3d69cd9e6 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -1,10 +1,11 @@ -use crate::container::Timestamp; -use anyhow::Context; -use bytes::{Buf, Bytes, BytesMut}; +use bytes::{Bytes, BytesMut}; use hang::catalog::{AAC, AudioCodec, AudioConfig, Container, H264, H265, VP9, VideoCodec, VideoConfig}; use mp4_atom::{Any, Atom, DecodeMaybe, Encode, Mdat, Moof, Moov, Trak}; use std::collections::HashMap; -use tokio::io::{AsyncRead, AsyncReadExt}; + +use super::Error; +use crate::Result; +use crate::container::Timestamp; /// Converts fMP4/CMAF files into MoQ broadcast streams using CMAF passthrough. /// @@ -23,12 +24,12 @@ use tokio::io::{AsyncRead, AsyncReadExt}; /// **Audio:** /// - AAC (MP4A) /// - Opus -pub struct Import { +pub struct Import { /// The broadcast being produced broadcast: moq_net::BroadcastProducer, /// The catalog being produced - catalog: crate::catalog::Producer, + catalog: crate::catalog::Producer, // A lookup to tracks in the broadcast tracks: HashMap, @@ -39,6 +40,10 @@ pub struct Import { // The latest moof header moof: Option, moof_size: usize, + + // Bytes carried across calls: a partial atom at the tail of one `decode` waits + // here for the rest to arrive on the next call. + buffer: BytesMut, } #[derive(PartialEq, Debug)] @@ -66,11 +71,11 @@ struct Fmp4Track { pending_sequence: Option, } -impl Import { +impl Import { /// Create a new CMAF importer that will write to the given broadcast. /// /// The broadcast will be populated with tracks as they're discovered in the fMP4 file. - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { + pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { Self { catalog, tracks: HashMap::default(), @@ -78,64 +83,70 @@ impl Import { moof: None, moof_size: 0, broadcast, + buffer: BytesMut::new(), } } - /// Decode from an asynchronous reader. - pub async fn decode_from(&mut self, reader: &mut T) -> anyhow::Result<()> { - let mut buffer = BytesMut::new(); - while reader.read_buf(&mut buffer).await? > 0 { - self.decode(&mut buffer)?; - } - - Ok(()) + /// Decode a buffer of bytes. + pub fn decode(&mut self, data: &[u8]) -> Result<()> { + self.buffer.extend_from_slice(data); + self.drain() } - /// Decode a buffer of bytes. - pub fn decode>(&mut self, buf: &mut T) -> anyhow::Result<()> { - let mut cursor = std::io::Cursor::new(buf); + /// Parse every whole top-level atom buffered so far, leaving any trailing + /// partial atom for the next call. + fn drain(&mut self) -> Result<()> { + // Parse complete atoms first, recording each one's byte range, then process + // them. Collecting up front keeps `self.buffer` un-borrowed while the handlers + // (`init`/`extract`) take `&mut self`. + let mut parsed = Vec::new(); let mut position = 0; + loop { + let mut cursor = std::io::Cursor::new(&self.buffer[position..]); + let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? else { + break; + }; + let size = cursor.position() as usize; + parsed.push((atom, position, size)); + position += size; + } - while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? { - // Process the parsed atom. - let size = cursor.position() as usize - position; + if position == 0 { + return Ok(()); + } - // The raw bytes of the atom we just parsed (not copied). - let raw = &cursor.get_ref().as_ref()[position..position + size]; + // Detach the fully-parsed prefix as a cheap ref-counted buffer so each mdat's + // raw bytes can be sliced out without copying or borrowing `self`. + let consumed = self.buffer.split_to(position).freeze(); + for (atom, start, size) in parsed { match atom { Any::Ftyp(_) | Any::Styp(_) => {} Any::Moov(moov) => { self.init(moov)?; } Any::Moof(moof) => { - anyhow::ensure!(self.moof.is_none(), "duplicate moof box"); + if self.moof.is_some() { + return Err(Error::DuplicateMoof.into()); + } self.moof.replace(moof); self.moof_size = size; } Any::Mdat(mdat) => { - self.extract(mdat, raw)?; + let raw = consumed.slice(start..start + size); + self.extract(mdat, &raw)?; } _ => { // Skip unknown atoms (e.g., sidx, which is optional and used for segment indexing) // These are safe to ignore and don't affect playback } } - - position = cursor.position() as usize; } - // Advance the buffer by the amount of data that was processed. - cursor.into_inner().advance(position); - Ok(()) } - pub fn is_initialized(&self) -> bool { - self.moov.is_some() - } - - fn init(&mut self, moov: Moov) -> anyhow::Result<()> { + fn init(&mut self, moov: Moov) -> Result<()> { // Clone the catalog to avoid the borrow checker. let mut catalog = self.catalog.clone(); let mut catalog = catalog.lock(); @@ -145,21 +156,29 @@ impl Import { let handler = &trak.mdia.hdlr.handler; let suffix = ".m4s"; + // Declare the track at the fMP4's native timescale. Frame timestamps are + // emitted at this same scale (see below), so they satisfy the track's + // timescale invariant and ride the wire for the relay, redundant with the + // timing already inside each CMAF fragment. let track = self.broadcast.unique_track(suffix)?; let kind = match handler.as_ref() { b"vide" => { let config = self.init_video(trak, &moov)?; - catalog.video.renditions.insert(track.name.clone(), config); + catalog.video.renditions.insert(track.name().to_string(), config); TrackKind::Video } b"soun" => { let config = self.init_audio(trak, &moov)?; - catalog.audio.renditions.insert(track.name.clone(), config); + catalog.audio.renditions.insert(track.name().to_string(), config); TrackKind::Audio } - b"sbtl" => anyhow::bail!("subtitle tracks are not supported"), - handler => anyhow::bail!("unknown track type: {:?}", handler), + b"sbtl" => return Err(Error::UnsupportedSubtitle.into()), + handler => { + let mut buf = [0u8; 4]; + buf[..handler.len().min(4)].copy_from_slice(&handler[..handler.len().min(4)]); + return Err(Error::UnknownTrackHandler(buf).into()); + } }; self.tracks.insert( @@ -183,7 +202,7 @@ impl Import { Ok(()) } - fn container(&self, trak: &Trak, moov: &Moov) -> anyhow::Result { + fn container(&self, trak: &Trak, moov: &Moov) -> Result { // Build a single-track init segment (ftyp+moov) for this track. { let ftyp = mp4_atom::Ftyp { @@ -223,20 +242,20 @@ impl Import { Ok(Container::Cmaf { init: buf.into(), - timescale: Some(trak.mdia.mdhd.timescale), - track_id: Some(trak.tkhd.track_id), + timescale: None, + track_id: None, }) } } - fn init_video(&mut self, trak: &Trak, moov: &Moov) -> anyhow::Result { + fn init_video(&mut self, trak: &Trak, moov: &Moov) -> Result { let container = self.container(trak, moov)?; let stsd = &trak.mdia.minf.stbl.stsd; let codec = match stsd.codecs.len() { - 0 => anyhow::bail!("missing codec"), + 0 => return Err(Error::MissingCodec.into()), 1 => &stsd.codecs[0], - _ => anyhow::bail!("multiple codecs"), + _ => return Err(Error::MultipleCodecs.into()), }; let config = match codec { @@ -293,8 +312,8 @@ impl Import { config.container = container; config } - mp4_atom::Codec::Unknown(unknown) => anyhow::bail!("unknown codec: {:?}", unknown), - unsupported => anyhow::bail!("unsupported codec: {:?}", unsupported), + mp4_atom::Codec::Unknown(unknown) => return Err(Error::UnknownCodec(*unknown).into()), + unsupported => return Err(Error::UnsupportedCodec(Box::new(unsupported.clone())).into()), }; Ok(config) @@ -306,7 +325,7 @@ impl Import { hvcc: &mp4_atom::Hvcc, visual: &mp4_atom::Visual, container: Container, - ) -> anyhow::Result { + ) -> Result { let mut description = BytesMut::new(); hvcc.encode_body(&mut description)?; @@ -326,14 +345,14 @@ impl Import { Ok(config) } - fn init_audio(&mut self, trak: &Trak, moov: &Moov) -> anyhow::Result { + fn init_audio(&mut self, trak: &Trak, moov: &Moov) -> Result { let container = self.container(trak, moov)?; let stsd = &trak.mdia.minf.stbl.stsd; let codec = match stsd.codecs.len() { - 0 => anyhow::bail!("missing codec"), + 0 => return Err(Error::MissingCodec.into()), 1 => &stsd.codecs[0], - _ => anyhow::bail!("multiple codecs"), + _ => return Err(Error::MultipleCodecs.into()), }; let config = match codec { @@ -342,7 +361,7 @@ impl Import { // TODO Also support mp4a.67 if desc.object_type_indication != 0x40 { - anyhow::bail!("unsupported codec: MPEG2"); + return Err(Error::UnsupportedMpeg2.into()); } let bitrate = desc.avg_bitrate.max(desc.max_bitrate); @@ -374,31 +393,31 @@ impl Import { config.container = container; config } - mp4_atom::Codec::Unknown(unknown) => anyhow::bail!("unknown codec: {:?}", unknown), - unsupported => anyhow::bail!("unsupported codec: {:?}", unsupported), + mp4_atom::Codec::Unknown(unknown) => return Err(Error::UnknownCodec(*unknown).into()), + unsupported => return Err(Error::UnsupportedCodec(Box::new(unsupported.clone())).into()), }; Ok(config) } // Extract all frames out of an mdat atom using CMAF passthrough. - fn extract(&mut self, mdat: Mdat, mdat_raw: &[u8]) -> anyhow::Result<()> { - let moov = self.moov.as_ref().context("missing moov box")?; - let moof = self.moof.take().context("missing moof box")?; + fn extract(&mut self, mdat: Mdat, mdat_raw: &[u8]) -> Result<()> { + let moov = self.moov.as_ref().ok_or(Error::NoMoov)?; + let moof = self.moof.take().ok_or(Error::NoMoof)?; let moof_size = self.moof_size; let header_size = mdat_raw.len() - mdat.data.len(); // Loop over all of the traf boxes in the moof. for traf in &moof.traf { let track_id = traf.tfhd.track_id; - let track = self.tracks.get_mut(&track_id).context("unknown track")?; + let track = self.tracks.get_mut(&track_id).ok_or(Error::UnknownTrack(track_id))?; // Find the track information in the moov let trak = moov .trak .iter() .find(|trak| trak.tkhd.track_id == track_id) - .context("unknown track")?; + .ok_or(Error::UnknownTrack(track_id))?; let trex = moov .mvex .as_ref() @@ -409,7 +428,7 @@ impl Import { let default_sample_size = trex.map(|trex| trex.default_sample_size).unwrap_or_default(); let default_sample_flags = trex.map(|trex| trex.default_sample_flags).unwrap_or_default(); - let tfdt = traf.tfdt.as_ref().context("missing tfdt box")?; + let tfdt = traf.tfdt.as_ref().ok_or(Error::MissingTfdt)?; let mut dts = tfdt.base_media_decode_time; let timescale = trak.mdia.mdhd.timescale as u64; @@ -417,7 +436,7 @@ impl Import { let mut track_data_start: Option = None; if traf.trun.is_empty() { - anyhow::bail!("missing trun box"); + return Err(Error::MissingTrun.into()); } // Keep track of the minimum and maximum timestamp for this track to compute the jitter. @@ -430,16 +449,16 @@ impl Import { if let Some(data_offset) = trun.data_offset { let base_offset = tfhd.base_data_offset.unwrap_or_default() as usize; - let data_offset: usize = data_offset.try_into().context("invalid data offset")?; + let data_offset: usize = data_offset.try_into().map_err(|_| Error::InvalidDataOffset)?; let relative_offset = data_offset .checked_sub(moof_size) .and_then(|v| v.checked_sub(header_size)) - .context("invalid data offset: underflow")?; + .ok_or(Error::InvalidDataOffset)?; offset = base_offset .checked_add(relative_offset) - .context("invalid data offset: overflow")?; + .ok_or(Error::InvalidDataOffset)?; } // Capture the actual start offset for this traf before consuming samples @@ -458,11 +477,17 @@ impl Import { .size .unwrap_or(tfhd.default_sample_size.unwrap_or(default_sample_size)) as usize; - let pts = (dts as i64 + entry.cts.unwrap_or_default() as i64) as u64; - let timestamp = crate::container::Timestamp::from_scale(pts, timescale)?; - - if offset + size > mdat.data.len() { - anyhow::bail!("invalid data offset"); + // Checked: a negative composition offset must not wrap into a huge u64 PTS. + let pts = dts + .checked_add_signed(entry.cts.unwrap_or_default() as i64) + .ok_or(Error::PtsOverflow)?; + // Preserve the fmp4 track's native timescale so a passthrough re-emit + // doesn't go through a lossy microsecond detour. + let timestamp = Timestamp::from_scale(pts, timescale)?; + + let sample_end = offset.checked_add(size).ok_or(Error::InvalidDataOffset)?; + if sample_end > mdat.data.len() { + return Err(Error::InvalidDataOffset.into()); } let keyframe = match track.kind { @@ -476,24 +501,24 @@ impl Import { contains_keyframe |= keyframe; - if timestamp >= max_timestamp.unwrap_or(Timestamp::ZERO) { + if max_timestamp.is_none_or(|max| timestamp >= max) { max_timestamp = Some(timestamp); } - if timestamp <= min_timestamp.unwrap_or(Timestamp::MAX) { + if min_timestamp.is_none_or(|min| timestamp <= min) { min_timestamp = Some(timestamp); } if let Some(last_timestamp) = track.last_timestamp && let Ok(duration) = timestamp.checked_sub(last_timestamp) - && duration < track.min_duration.unwrap_or(Timestamp::MAX) + && track.min_duration.is_none_or(|min| duration < min) { track.min_duration = Some(duration); } track.last_timestamp = Some(timestamp); - dts += duration as u64; - offset += size; + dts = dts.checked_add(duration as u64).ok_or(Error::PtsOverflow)?; + offset = sample_end; } } @@ -511,13 +536,14 @@ impl Import { // The per-track sample range must be in bounds of the original mdat. // If not, the parsed sample sizes/offsets disagree with the actual data // and we cannot safely emit a passthrough fragment with rewritten offsets. - anyhow::ensure!( - track_data_start <= track_data_end && track_data_end <= mdat.data.len(), - "track sample range {}..{} is out of bounds of mdat (len {})", - track_data_start, - track_data_end, - mdat.data.len() - ); + if !(track_data_start <= track_data_end && track_data_end <= mdat.data.len()) { + return Err(Error::SampleRangeOutOfBounds { + start: track_data_start, + end: track_data_end, + len: mdat.data.len(), + } + .into()); + } let track_mdat_data = &mdat.data[track_data_start..track_data_end]; let mut adjusted_moof = single_traf_moof; @@ -581,17 +607,26 @@ impl Import { None => track.track.append_group()?, } } else { - track.group.take().context("no keyframe at start")? + track.group.take().ok_or(Error::NoKeyframe)? }; - g.write_frame(fragment_bytes)?; + // Carry the fragment's earliest presentation time as the frame timestamp, + // in the track's native timescale. The relay reads it off the wire; the + // consumer still drives playback from the fragment's internal timing. + let timestamp = min_timestamp.ok_or(Error::MissingTrun)?; + let _ = timestamp; + let mut frame = g.create_frame(moq_net::Frame { + size: fragment_bytes.len() as u64, + })?; + frame.write(fragment_bytes)?; + frame.finish()?; track.group = Some(g); if let (Some(min), Some(max), Some(min_duration)) = (min_timestamp, max_timestamp, track.min_duration) { let jitter = max - min + min_duration; - if jitter < track.jitter.unwrap_or(Timestamp::MAX) { + if track.jitter.is_none_or(|j| jitter < j) { track.jitter = Some(jitter); let mut catalog = self.catalog.lock(); @@ -601,17 +636,17 @@ impl Import { let config = catalog .video .renditions - .get_mut(&track.track.name) - .context("missing video config")?; - config.jitter = Some(jitter.convert()?); + .get_mut(track.track.name()) + .ok_or_else(|| Error::MissingVideoTrack(track.track.name().to_string()))?; + config.jitter = moq_net::Time::from_scale(jitter.as_micros() as u64, 1_000_000).ok(); } TrackKind::Audio => { let config = catalog .audio .renditions - .get_mut(&track.track.name) - .context("missing audio config")?; - config.jitter = Some(jitter.convert()?); + .get_mut(track.track.name()) + .ok_or_else(|| Error::MissingAudioTrack(track.track.name().to_string()))?; + config.jitter = moq_net::Time::from_scale(jitter.as_micros() as u64, 1_000_000).ok(); } } } @@ -622,9 +657,9 @@ impl Import { } } -impl Import { +impl Import { /// Finish all tracks, flushing current groups. - pub fn finish(&mut self) -> anyhow::Result<()> { + pub fn finish(&mut self) -> Result<()> { for track in self.tracks.values_mut() { if let Some(mut g) = track.group.take() { g.finish()?; @@ -638,7 +673,7 @@ impl Import { /// /// Broadcast-wide: every track inside this fMP4 import advances together; per-track /// control is intentionally not exposed. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { + pub fn seek(&mut self, sequence: u64) -> Result<()> { for track in self.tracks.values_mut() { if let Some(mut g) = track.group.take() { g.finish()?; @@ -649,17 +684,17 @@ impl Import { } } -impl Drop for Import { +impl Drop for Import { fn drop(&mut self) { let mut catalog = self.catalog.lock(); for track in self.tracks.values() { match track.kind { TrackKind::Video => { - catalog.video.renditions.remove(&track.track.name); + catalog.video.renditions.remove(track.track.name()); } TrackKind::Audio => { - catalog.audio.renditions.remove(&track.track.name); + catalog.audio.renditions.remove(track.track.name()); } } } diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index 7075a19a9..6cbbeefde 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -19,9 +19,9 @@ fn run_fmp4(data: &[u8]) -> crate::catalog::hang::Catalog { let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.clone()); - let mut buf = bytes::BytesMut::from(data); + let buf = bytes::BytesMut::from(data); // Ignore errors from incomplete/malformed trailing fragments in test files. - let _ = fmp4.decode(&mut buf); + let _ = fmp4.decode(&buf); catalog.snapshot() } @@ -177,18 +177,17 @@ async fn test_seek_sets_initial_sequence() { } // Decode init so the tracks exist, then seek, then decode the fragments. - fmp4.decode(&mut init_buf).unwrap(); - assert!(fmp4.is_initialized()); + fmp4.decode(&init_buf).unwrap(); let snap = catalog.snapshot(); let video_name = snap.video.renditions.keys().next().expect("video track").clone(); let mut video_track = broadcast_consumer - .subscribe_track(&moq_net::Track::new(&video_name)) + .subscribe_track(&moq_net::Track::new(video_name.as_str())) .expect("video track should exist"); fmp4.seek(100).unwrap(); // Trailing partial fragments may error; ignore. - let _ = fmp4.decode(&mut frag_buf); + let _ = fmp4.decode(&frag_buf); fmp4.finish().unwrap(); let sequences = drain_group_sequences(&mut video_track); @@ -209,16 +208,16 @@ async fn test_seek_sets_initial_sequence() { #[tokio::test] async fn test_msf_catalog_roundtrip() { let mut broadcast = moq_net::Broadcast::new().produce(); - // Take the consumer before adding tracks; subscribe_track is called after the + // Take the consumer before adding tracks; track() is called after the // MSF catalog track has been created by `catalog::Producer::new`. let consumer = broadcast.consume(); let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog); let data = include_bytes!("test_data/bbb.mp4"); - let mut buf = bytes::BytesMut::from(&data[..]); + let buf = bytes::BytesMut::from(&data[..]); // Trailing fragments may error out (e.g. partial mdat); ignore. - let _ = fmp4.decode(&mut buf); + let _ = fmp4.decode(&buf); let track = consumer .subscribe_track(&moq_net::Track::new(moq_msf::DEFAULT_NAME)) diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index f0a5517d2..4b43ea8ba 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -25,15 +25,18 @@ use mp4_atom::Atom; use crate::container::{Container, Frame, Timestamp}; -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Clone, thiserror::Error)] #[non_exhaustive] pub enum Error { #[error("mp4: {0}")] - Mp4(#[from] mp4_atom::Error), + Mp4(std::sync::Arc), #[error("moq: {0}")] Moq(#[from] moq_net::Error), + #[error("missing keyframe: a group must open on a keyframe")] + MissingKeyframe(#[from] crate::container::MissingKeyframe), + #[error("timestamp overflow")] TimestampOverflow(#[from] moq_net::TimeOverflow), @@ -46,13 +49,13 @@ pub enum Error { #[error("PTS overflow")] PtsOverflow, - #[error("no moof found in CMAF frame data")] + #[error("missing moof")] NoMoof, - #[error("no mdat found in CMAF frame data")] + #[error("missing mdat")] NoMdat, - #[error("no moov found in init data")] + #[error("missing moov")] NoMoov, #[error("no tracks in moov")] @@ -64,13 +67,75 @@ pub enum Error { #[error("can't synthesize CMAF init for {0}")] UnsupportedSynthesis(String), + #[error("subtitle tracks are not supported")] + UnsupportedSubtitle, + + #[error("unknown track handler: {0:?}")] + UnknownTrackHandler([u8; 4]), + + #[error("missing codec")] + MissingCodec, + + #[error("multiple codecs")] + MultipleCodecs, + + #[error("unknown codec: {0:?}")] + UnknownCodec(mp4_atom::FourCC), + + #[error("unsupported codec: {0:?}")] + UnsupportedCodec(Box), + + #[error("unsupported codec: MPEG2")] + UnsupportedMpeg2, + + #[error("duplicate moof")] + DuplicateMoof, + + #[error("missing trun")] + MissingTrun, + + #[error("missing tfdt")] + MissingTfdt, + #[error("video codec {0} needs a description (codec config record) to synthesize a CMAF init")] MissingVideoDescription(String), + #[error("video track {0} missing in catalog")] + MissingVideoTrack(String), + + #[error("audio track {0} missing in catalog")] + MissingAudioTrack(String), + + #[error("invalid data offset")] + InvalidDataOffset, + + #[error("unknown track {0}")] + UnknownTrack(u32), + + #[error("no keyframe at start of group")] + NoKeyframe, + + #[error("track sample range {start}..{end} is out of bounds of mdat (len {len})")] + SampleRangeOutOfBounds { start: usize, end: usize, len: usize }, + + #[error("no catalog snapshot")] + NoCatalogSnapshot, + + #[error("encode_fragment called with no frames")] + NoFrames, + #[error("audio codec {0} needs a description (AudioSpecificConfig) to synthesize a CMAF init")] MissingAudioDescription(String), } +impl From for Error { + fn from(err: mp4_atom::Error) -> Self { + Error::Mp4(std::sync::Arc::new(err)) + } +} + +pub type Result = std::result::Result; + /// CMAF container: encodes/decodes a single track's moof+mdat fragments. /// /// Build from a CMAF init segment with [`Wire::from_init`], or wrap a @@ -90,7 +155,7 @@ impl Wire { } /// Parse a CMAF init segment (ftyp+moov), extracting the single track. - pub fn from_init(init_data: &[u8]) -> Result { + pub fn from_init(init_data: &[u8]) -> Result { use mp4_atom::DecodeMaybe; let mut cursor = std::io::Cursor::new(init_data); @@ -114,7 +179,7 @@ impl Wire { impl Container for Wire { type Error = Error; - fn write(&self, group: &mut moq_net::GroupProducer, frames: &[Frame]) -> Result<(), Self::Error> { + fn write(&self, group: &mut moq_net::GroupProducer, frames: &[Frame]) -> std::result::Result<(), Self::Error> { let timescale = self.trak.mdia.mdhd.timescale as u64; let track_id = self.trak.tkhd.track_id; encode(group, frames, timescale, track_id) @@ -124,7 +189,7 @@ impl Container for Wire { &self, group: &mut moq_net::GroupConsumer, waiter: &kio::Waiter, - ) -> Poll>, Self::Error>> { + ) -> Poll>, Self::Error>> { use std::task::ready; let Some(data) = ready!(group.poll_read_frame(waiter)?) else { @@ -136,7 +201,7 @@ impl Container for Wire { } } -pub(crate) fn decode(data: Bytes, timescale: u64) -> Result, Error> { +pub(crate) fn decode(data: Bytes, timescale: u64) -> Result> { use mp4_atom::DecodeMaybe; let mut cursor = std::io::Cursor::new(&data); @@ -170,25 +235,38 @@ pub(crate) fn decode(data: Bytes, timescale: u64) -> Result, Error> { let end = offset + size; if end > mdat_data.len() { - return Ok(frames); + return Err(Error::SampleRangeOutOfBounds { + start: offset, + end, + len: mdat_data.len(), + }); } let cts = entry.cts.unwrap_or_default() as i64; let pts = dts.checked_add_signed(cts).ok_or(Error::PtsOverflow)?; + // Preserve the fmp4 track's native scale through the pipeline. let timestamp = Timestamp::from_scale(pts, timescale)?; let payload = Bytes::copy_from_slice(&mdat_data[offset..end]); let flags = entry.flags.unwrap_or(0); // depends_on_no_other (bits 24-25 == 0x2) means keyframe let keyframe = (flags >> 24) & 0x3 == 0x2; + // Carry the sample-duration through at the track's scale when present, so + // the jitter buffer can use it and an exporter can write it back. + let sample_duration = entry.duration.or(default_duration); + let duration = sample_duration + .map(|d| Timestamp::from_scale(d as u64, timescale)) + .transpose()?; + frames.push(Frame { timestamp, payload, keyframe, + duration, }); offset = end; - dts += entry.duration.or(default_duration).unwrap_or(0) as u64; + dts += sample_duration.unwrap_or(0) as u64; } } @@ -200,14 +278,18 @@ pub(crate) fn encode( frames: &[Frame], timescale: u64, track_id: u32, -) -> Result<(), Error> { +) -> Result<()> { if frames.is_empty() { return Ok(()); } let sequence_number = group.frame_count() as u32; let bytes = encode_fragment(track_id, timescale, sequence_number, frames)?; - let mut writer = group.create_frame(bytes.len().into())?; + // The fragment may carry several samples; the net frame's timestamp is the + // fragment's earliest presentation time so a relay can order it. + let mut writer = group.create_frame(moq_net::Frame { + size: bytes.len() as u64, + })?; writer.write(bytes)?; writer.finish()?; @@ -222,27 +304,30 @@ pub(crate) fn encode( /// caller-supplied `timescale`. /// /// Returns an empty `Bytes` when `frames` is empty. -pub(crate) fn encode_fragment( - track_id: u32, - timescale: u64, - sequence_number: u32, - frames: &[Frame], -) -> Result { +pub(crate) fn encode_fragment(track_id: u32, timescale: u64, sequence_number: u32, frames: &[Frame]) -> Result { use mp4_atom::Encode; if frames.is_empty() { return Ok(Bytes::new()); } - let dts = (frames[0].timestamp.as_micros() * timescale as u128 / 1_000_000) as u64; + // Re-express the first frame's timestamp at the target track's scale. When the + // importer preserved the source scale (the common passthrough case), this is a + // no-op; otherwise it's a single rescale rather than the legacy `micros * scale + // / 1_000_000` round-trip. + let dts = frames[0].timestamp.as_scale(timescale) as u64; let entries: Vec<_> = frames .iter() .map(|f| { let flags = if f.keyframe { 0x0200_0000 } else { 0x0001_0000 }; + // Write the sample-duration back at the track's scale when we know it, so + // fMP4 -> fMP4 round-trips it. Frames without one stay byte-identical. + let duration = f.duration.map(|d| d.as_scale(timescale) as u32); mp4_atom::TrunEntry { size: Some(f.payload.len() as u32), flags: Some(flags), + duration, ..Default::default() } }) @@ -295,7 +380,7 @@ pub(crate) fn synthesize_video_trak( timescale: u64, config: &VideoConfig, description: Option<&[u8]>, -) -> Result { +) -> Result { let width = config.coded_width.unwrap_or(0) as u16; let height = config.coded_height.unwrap_or(0) as u16; let visual = mp4_atom::Visual { @@ -311,7 +396,7 @@ pub(crate) fn synthesize_video_trak( let sample_entry = match &config.codec { VideoCodec::H264(_) => { let mut cursor = std::io::Cursor::new(require_description()?); - let avcc = mp4_atom::Avcc::decode_body(&mut cursor).map_err(Error::Mp4)?; + let avcc = mp4_atom::Avcc::decode_body(&mut cursor).map_err(Error::from)?; mp4_atom::Codec::from(mp4_atom::Avc1 { visual, avcc, @@ -320,7 +405,7 @@ pub(crate) fn synthesize_video_trak( } VideoCodec::H265(h265) => { let mut cursor = std::io::Cursor::new(require_description()?); - let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).map_err(Error::Mp4)?; + let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).map_err(Error::from)?; // `in_band` (catalog) ↔ hev1 sample entry; otherwise hvc1. if h265.in_band { mp4_atom::Codec::from(mp4_atom::Hev1 { @@ -336,6 +421,11 @@ pub(crate) fn synthesize_video_trak( }) } } + VideoCodec::AV1(av1) => mp4_atom::Codec::from(mp4_atom::Av01 { + visual, + av1c: crate::codec::av1::av1c_from_av1(av1), + ..Default::default() + }), VideoCodec::VP8 => mp4_atom::Codec::from(mp4_atom::Vp08 { visual, vpcc: crate::codec::vp8::vpcc(), @@ -353,11 +443,7 @@ pub(crate) fn synthesize_video_trak( } /// Synthesize a CMAF `Trak` for an audio rendition that has no init segment. -pub(crate) fn synthesize_audio_trak( - track_id: u32, - timescale: u64, - config: &AudioConfig, -) -> Result { +pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &AudioConfig) -> Result { use mp4_atom::Decode; let audio = mp4_atom::Audio { @@ -492,3 +578,102 @@ pub(crate) fn default_video_timescale(config: &VideoConfig) -> u64 { 90000 } } + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(micros: u64) -> Timestamp { + Timestamp::from_micros(micros).unwrap() + } + + #[test] + fn decode_reads_trun_sample_duration() { + use mp4_atom::Encode; + + // Microsecond timescale so each tick maps 1:1 to the Timestamp's µs. + // decode() walks the mdat by sample size and ignores data_offset, so a + // hand-built moof+mdat with explicit per-sample durations is enough. + let timescale = 1_000_000; + let moof = mp4_atom::Moof { + mfhd: mp4_atom::Mfhd { sequence_number: 0 }, + traf: vec![mp4_atom::Traf { + tfhd: mp4_atom::Tfhd { + track_id: 1, + ..Default::default() + }, + tfdt: Some(mp4_atom::Tfdt { + base_media_decode_time: 0, + }), + trun: vec![mp4_atom::Trun { + data_offset: Some(0), + entries: vec![ + mp4_atom::TrunEntry { + size: Some(2), + duration: Some(33_333), + ..Default::default() + }, + mp4_atom::TrunEntry { + size: Some(2), + duration: Some(33_333), + ..Default::default() + }, + ], + }], + ..Default::default() + }], + }; + + let mut buf = Vec::new(); + moof.encode(&mut buf).unwrap(); + mp4_atom::Mdat { + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + } + .encode(&mut buf) + .unwrap(); + + let frames = decode(Bytes::from(buf), timescale).unwrap(); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].timestamp, ts(0)); + assert_eq!(frames[0].duration, Some(ts(33_333))); + assert_eq!(frames[1].timestamp, ts(33_333)); + assert_eq!(frames[1].duration, Some(ts(33_333))); + } + + #[test] + fn duration_round_trips_through_encode() { + // A frame with a known duration must survive encode -> decode. + let timescale = 1_000_000; + let input = vec![Frame { + timestamp: ts(0), + payload: Bytes::from_static(&[0xDE, 0xAD]), + keyframe: true, + duration: Some(ts(33_333)), + }]; + + let fragment = encode_fragment(1, timescale, 0, &input).unwrap(); + let frames = decode(fragment, timescale).unwrap(); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].duration, Some(ts(33_333))); + } + + #[test] + fn decode_without_duration_reports_none() { + // encode_fragment writes no sample-duration for a duration-less frame, + // so decode must report None (and output stays byte-identical to before). + let timescale = 90_000; + let frames = vec![Frame { + timestamp: ts(0), + payload: Bytes::from_static(&[0xDE, 0xAD]), + keyframe: true, + duration: None, + }]; + + let fragment = encode_fragment(1, timescale, 0, &frames).unwrap(); + let frames = decode(fragment, timescale).unwrap(); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].duration, None); + } +} diff --git a/rs/moq-mux/src/container/hls/import.rs b/rs/moq-mux/src/container/hls/import.rs index 1c8f7f8d5..6c1759e24 100644 --- a/rs/moq-mux/src/container/hls/import.rs +++ b/rs/moq-mux/src/container/hls/import.rs @@ -1,28 +1,31 @@ -//! HLS (HTTP Live Streaming) ingest built on top of fMP4. +//! HLS import: pull an HLS master/media playlist and publish it into MoQ. //! -//! This module provides reusable logic to ingest HLS master/media playlists and -//! feed their fMP4 segments into a `hang` broadcast. +//! Watches an HLS master or media playlist, downloads each fMP4 segment as it +//! appears, and feeds it through moq-mux's fMP4 importer (which publishes a +//! `hang` broadcast + catalog). Classic HLS only for now (no LL-HLS partial +//! segments on the import side). use std::collections::HashMap; use std::collections::hash_map::Entry; use std::path::PathBuf; use std::time::Duration; -use anyhow::Context; use bytes::Bytes; use m3u8_rs::{ AlternativeMedia, AlternativeMediaType, Map, MasterPlaylist, MediaPlaylist, MediaSegment, Resolution, VariantStream, }; +use moq_mux::catalog::Producer as CatalogProducer; +use moq_mux::container::fmp4::Import as Fmp4; use reqwest::Client; use tracing::{debug, info, warn}; use url::Url; -use crate::container::fmp4::Import as Fmp4; +use crate::{Error, Result}; -/// Configuration for the single-rendition HLS ingest loop. +/// Configuration for the single-rendition HLS import loop. #[derive(Clone)] pub struct Config { - /// The master or media playlist URL or file path to ingest. + /// The master or media playlist URL or file path to import. pub playlist: String, /// An optional HTTP client to use for fetching the playlist and segments. @@ -38,9 +41,9 @@ impl Config { /// Parse the playlist string into a URL. /// If it starts with http:// or https://, parse as URL. /// Otherwise, treat as a file path and convert to file:// URL. - fn parse_playlist(&self) -> anyhow::Result { + fn parse_playlist(&self) -> Result { if self.playlist.starts_with("http://") || self.playlist.starts_with("https://") { - Url::parse(&self.playlist).context("invalid playlist URL") + Url::parse(&self.playlist).map_err(|_| Error::InvalidPlaylistUrl) } else { let path = PathBuf::from(&self.playlist); let absolute = if path.is_absolute() { @@ -48,12 +51,12 @@ impl Config { } else { std::env::current_dir()?.join(path) }; - Url::from_file_path(&absolute).ok().context("invalid file path") + Url::from_file_path(&absolute).map_err(|_| Error::InvalidFilePath) } } } -/// Result of a single ingest step. +/// Result of a single import step. struct StepOutcome { /// Number of media segments written during this step. pub wrote_segments: usize, @@ -61,16 +64,16 @@ struct StepOutcome { pub target_duration: Option, } -/// HLS ingest that pulls an HLS media playlist and feeds the bytes into the fMP4 ingest. +/// HLS import that pulls an HLS media playlist and feeds the bytes into the fMP4 importer. /// -/// Provides `init()` to prime the ingest with initial segments, and `service()` -/// to run the continuous ingest loop. +/// Provides `init()` to prime the importer with initial segments, and `run()` +/// to run the continuous import loop. pub struct Import { /// Broadcast that all CMAF importers write into. broadcast: moq_net::BroadcastProducer, /// The catalog being produced. - catalog: crate::catalog::Producer, + catalog: CatalogProducer, /// fMP4 importers for each discovered video rendition. /// Each importer feeds a separate MoQ track but shares the same catalog. @@ -111,12 +114,8 @@ impl TrackState { } impl Import { - /// Create a new HLS ingest that will write into the given broadcast. - pub fn new( - broadcast: moq_net::BroadcastProducer, - catalog: crate::catalog::Producer, - cfg: Config, - ) -> anyhow::Result { + /// Create a new HLS import that will write into the given broadcast. + pub fn new(broadcast: moq_net::BroadcastProducer, catalog: CatalogProducer, cfg: Config) -> Result { let base_url = cfg.parse_playlist()?; let client = cfg.client.unwrap_or_else(|| { Client::builder() @@ -139,7 +138,7 @@ impl Import { /// Fetch the latest playlist, download the init segment, and prime the importer with a buffer of segments. /// /// Returns the number of segments buffered during initialization. - pub async fn init(&mut self) -> anyhow::Result<()> { + pub async fn init(&mut self) -> Result<()> { let buffered = self.prime().await?; if buffered == 0 { warn!("HLS playlist had no new segments during init step"); @@ -149,8 +148,8 @@ impl Import { Ok(()) } - /// Run the ingest loop until cancelled. - pub async fn run(&mut self) -> anyhow::Result<()> { + /// Run the import loop until cancelled. + pub async fn run(&mut self) -> Result<()> { loop { let outcome = self.step().await?; let delay = self.refresh_delay(outcome.target_duration, outcome.wrote_segments); @@ -159,7 +158,7 @@ impl Import { wrote_segments = outcome.wrote_segments, target_duration = ?outcome.target_duration, delay_secs = delay.as_secs_f32(), - "HLS ingest step complete" + "HLS import step complete" ); tokio::time::sleep(delay).await; @@ -167,7 +166,7 @@ impl Import { } /// Internal: fetch the latest playlist, download the init segment, and buffer segments. - async fn prime(&mut self) -> anyhow::Result { + async fn prime(&mut self) -> Result { self.ensure_tracks().await?; let mut buffered = 0usize; @@ -176,7 +175,7 @@ impl Import { // Prime all discovered video variants. // // Move the video track states out of `self` so we can safely mutate both - // the ingest and the tracks without running into borrow checker issues. + // the importer and the tracks without running into borrow checker issues. let video_tracks = std::mem::take(&mut self.video); for (index, mut track) in video_tracks.into_iter().enumerate() { let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; @@ -200,12 +199,12 @@ impl Import { Ok(buffered) } - /// Perform a single ingest step for all active tracks. + /// Perform a single import step for all active tracks. /// /// This fetches the current media playlists, consumes any fresh segments, /// and returns how many segments were written along with the target /// duration to guide scheduling of the next step. - async fn step(&mut self) -> anyhow::Result { + async fn step(&mut self) -> Result { self.ensure_tracks().await?; let mut wrote = 0usize; @@ -245,7 +244,7 @@ impl Import { }) } - /// Compute the delay before the next ingest step should run. + /// Compute the delay before the next import step should run. fn refresh_delay(&self, target_duration: Option, wrote_segments: usize) -> Duration { let base = target_duration .map(|dur| Duration::from_secs(dur.max(1))) @@ -257,17 +256,16 @@ impl Import { base } - async fn fetch_media_playlist(&self, url: Url) -> anyhow::Result { + async fn fetch_media_playlist(&self, url: Url) -> Result { let body = self.fetch_bytes(url).await?; // Nom errors take ownership of the input, so we need to stringify any error messages. - let playlist = m3u8_rs::parse_media_playlist_res(&body) - .map_err(|e| anyhow::anyhow!("failed to parse media playlist: {}", e))?; + let playlist = m3u8_rs::parse_media_playlist_res(&body).map_err(|e| Error::ParsePlaylist(e.to_string()))?; Ok(playlist) } - async fn ensure_tracks(&mut self) -> anyhow::Result<()> { + async fn ensure_tracks(&mut self) -> Result<()> { // Tracks already discovered. if !self.video.is_empty() { return Ok(()); @@ -276,7 +274,9 @@ impl Import { let body = self.fetch_bytes(self.base_url.clone()).await?; if let Ok((_, master)) = m3u8_rs::parse_master_playlist(&body) { let variants = select_variants(&master); - anyhow::ensure!(!variants.is_empty(), "no usable variants found in master playlist"); + if variants.is_empty() { + return Err(Error::NoVariants); + } // Create a video track state for every usable variant. for variant in &variants { @@ -319,7 +319,7 @@ impl Import { track: &mut TrackState, playlist: &MediaPlaylist, limit: Option, - ) -> anyhow::Result { + ) -> Result { self.ensure_init_segment(kind, track, playlist).await?; let next_seq = track.next_sequence.unwrap_or(0); @@ -383,27 +383,24 @@ impl Import { kind: TrackKind, track: &mut TrackState, playlist: &MediaPlaylist, - ) -> anyhow::Result<()> { + ) -> Result<()> { if track.init_ready { return Ok(()); } - let map = self.find_map(playlist).context("playlist missing EXT-X-MAP")?; + let map = self.find_map(playlist).ok_or(Error::MissingMap)?; let url = resolve_uri(&track.playlist, &map.uri)?; - let mut bytes = self.fetch_bytes(url).await?; + let bytes = self.fetch_bytes(url).await?; let importer = match kind { TrackKind::Video(index) => self.ensure_video_importer_for(index), TrackKind::Audio => self.ensure_audio_importer(), }; - importer.decode(&mut bytes).context("init segment parse error")?; - - anyhow::ensure!(bytes.is_empty(), "init segment was not fully consumed"); - anyhow::ensure!( - importer.is_initialized(), - "init segment did not initialize the importer" - ); + // The importer buffers internally, so a fully-parsed init segment leaves it + // initialized; any trailing partial atom just waits for the next segment. A + // segment that never yields a moov surfaces later as a decode error. + importer.decode(&bytes)?; track.init_ready = true; info!(?kind, "loaded HLS init segment"); @@ -416,11 +413,13 @@ impl Import { track: &mut TrackState, segment: &MediaSegment, sequence: u64, - ) -> anyhow::Result<()> { - anyhow::ensure!(!segment.uri.is_empty(), "encountered segment with empty URI"); + ) -> Result<()> { + if segment.uri.is_empty() { + return Err(Error::EmptySegmentUri); + } let url = resolve_uri(&track.playlist, &segment.uri)?; - let mut bytes = self.fetch_bytes(url).await?; + let bytes = self.fetch_bytes(url).await?; // Ensure the importer is initialized before processing fragments // Use track.init_ready to avoid borrowing issues @@ -436,15 +435,7 @@ impl Import { TrackKind::Audio => self.ensure_audio_importer(), }; - // Final check after ensuring init segment - if !importer.is_initialized() { - return Err(anyhow::anyhow!( - "importer not initialized for {:?} after ensure_init_segment - init segment processing failed", - kind - )); - } - - importer.decode(&mut bytes).context("failed to parse media segment")?; + importer.decode(&bytes)?; track.next_sequence = Some(sequence + 1); Ok(()) @@ -454,15 +445,15 @@ impl Import { playlist.segments.iter().find_map(|segment| segment.map.as_ref()) } - async fn fetch_bytes(&self, url: Url) -> anyhow::Result { + async fn fetch_bytes(&self, url: Url) -> Result { if url.scheme() == "file" { - let path = url.to_file_path().ok().context("invalid file URL")?; - let bytes = tokio::fs::read(&path).await.context("failed to read file")?; + let path = url.to_file_path().map_err(|_| Error::InvalidFileUrl)?; + let bytes = tokio::fs::read(&path).await.map_err(Error::from)?; Ok(Bytes::from(bytes)) } else { - let response = self.client.get(url).send().await?; - let response = response.error_for_status()?; - let bytes = response.bytes().await.context("failed to read response body")?; + let response = self.client.get(url).send().await.map_err(Error::from)?; + let response = response.error_for_status().map_err(Error::from)?; + let bytes = response.bytes().await.map_err(Error::from)?; Ok(bytes) } } @@ -612,9 +603,9 @@ mod tests { } #[test] - fn hls_ingest_starts_without_importers() { + fn hls_import_starts_without_importers() { let mut broadcast = moq_net::Broadcast::new().produce(); - let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let catalog = CatalogProducer::new(&mut broadcast).unwrap(); let url = "https://example.com/master.m3u8".to_string(); let cfg = Config::new(url); let hls = Import::new(broadcast, catalog, cfg).unwrap(); diff --git a/rs/moq-mux/src/container/hls/mod.rs b/rs/moq-mux/src/container/hls/mod.rs index 84eb90c1c..f199633fd 100644 --- a/rs/moq-mux/src/container/hls/mod.rs +++ b/rs/moq-mux/src/container/hls/mod.rs @@ -7,3 +7,47 @@ mod import; pub use import::*; + +/// HLS ingest errors. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("invalid playlist URL")] + InvalidPlaylistUrl, + + #[error("invalid file path")] + InvalidFilePath, + + #[error("invalid file URL")] + InvalidFileUrl, + + #[error("failed to parse media playlist: {0}")] + ParsePlaylist(String), + + #[error("no usable variants found in master playlist")] + NoVariants, + + #[error("playlist missing EXT-X-MAP")] + MissingMap, + + #[error("init segment was not fully consumed")] + InitNotConsumed, + + #[error("init segment did not initialize the importer")] + InitNotInitialized, + + #[error("encountered segment with empty URI")] + EmptySegmentUri, + + #[error("importer not initialized for {0:?} after ensure_init_segment - init segment processing failed")] + ImporterNotInitialized(String), + + #[error("url parse: {0}")] + UrlParse(#[from] url::ParseError), + + #[error("reqwest: {0}")] + Reqwest(#[from] reqwest::Error), + + #[error("io: {0}")] + Io(#[from] std::io::Error), +} diff --git a/rs/moq-mux/src/container/jitter.rs b/rs/moq-mux/src/container/jitter.rs index d99c0c732..726b5d257 100644 --- a/rs/moq-mux/src/container/jitter.rs +++ b/rs/moq-mux/src/container/jitter.rs @@ -1,5 +1,6 @@ -use crate::container::Timestamp; +use std::time::Duration; +use crate::container::Timestamp; /// Tracks the catalog `jitter` for a video/audio track: the maximum delay before a frame can /// be emitted, so a player sizes its buffer to at least this much. /// @@ -10,14 +11,18 @@ use crate::container::Timestamp; /// /// A non-reordered stream reports the frame duration; a B-frame stream reports the deeper /// reorder delay (e.g. up to 3 consecutive B-frames is 3x the frame duration). +/// +/// Both contributions are kept as scale-free [`Duration`]s: the inputs are `Timestamp`s that +/// may carry different timescales (frame PTS vs a 90 kHz reorder delay), and `Timestamp` +/// arithmetic panics across scales, so they are converted at the boundary before comparison. #[derive(Default)] pub struct Jitter { last_timestamp: Option, - min_duration: Option, - max_reorder: Timestamp, + min_duration: Option, + max_reorder: Duration, /// Last value handed back from [`observe`](Self::observe) / /// [`observe_reorder`](Self::observe_reorder), so they only report on a change. - reported: Option, + reported: Option, } impl Jitter { @@ -26,23 +31,26 @@ impl Jitter { } /// Record a frame's presentation timestamp (decode order), updating the minimum frame - /// duration. Returns the new jitter as a [`moq_net::Time`] if it changed, else `None`. - /// The first observation and non-monotonic timestamps (B-frames) only update state. - pub fn observe(&mut self, ts: Timestamp) -> Option { + /// duration. Returns the new jitter as a [`Duration`] if it changed, else `None`. The + /// first observation and non-monotonic timestamps (B-frames) only update state. + pub fn observe(&mut self, ts: Timestamp) -> Option { if let Some(last) = self.last_timestamp.replace(ts) && let Ok(duration) = ts.checked_sub(last) && !duration.is_zero() - && duration < self.min_duration.unwrap_or(Timestamp::MAX) { - self.min_duration = Some(duration); + let duration = Duration::from(duration); + self.min_duration = Some(match self.min_duration { + Some(min) => min.min(duration), + None => duration, + }); } self.report() } /// Record a frame's reorder delay (`PTS - DTS`), updating the maximum. Returns the new - /// jitter as a [`moq_net::Time`] if it changed, else `None`. - pub fn observe_reorder(&mut self, reorder: Timestamp) -> Option { - self.max_reorder = self.max_reorder.max(reorder); + /// jitter as a [`Duration`] if it changed, else `None`. + pub fn observe_reorder(&mut self, reorder: Timestamp) -> Option { + self.max_reorder = self.max_reorder.max(Duration::from(reorder)); self.report() } @@ -50,22 +58,22 @@ impl Jitter { /// the change-detection of [`observe`](Self::observe). Used to seed a freshly created /// catalog rendition with whatever has accumulated, since per-frame updates before the /// rendition exists would otherwise be lost. - pub fn current(&self) -> Option { + pub fn current(&self) -> Option { let jitter = self.combined(); - (!jitter.is_zero()).then(|| jitter.convert().ok()).flatten() + (!jitter.is_zero()).then_some(jitter) } - fn combined(&self) -> Timestamp { - self.min_duration.unwrap_or(Timestamp::ZERO).max(self.max_reorder) + fn combined(&self) -> Duration { + self.min_duration.unwrap_or(Duration::ZERO).max(self.max_reorder) } /// Report the current jitter only when it changes. - fn report(&mut self) -> Option { + fn report(&mut self) -> Option { let jitter = self.combined(); if jitter.is_zero() || self.reported == Some(jitter) { return None; } self.reported = Some(jitter); - jitter.convert().ok() + Some(jitter) } } diff --git a/rs/moq-mux/src/container/legacy/mod.rs b/rs/moq-mux/src/container/legacy/mod.rs index 0e7bbcf42..5f77ca6b1 100644 --- a/rs/moq-mux/src/container/legacy/mod.rs +++ b/rs/moq-mux/src/container/legacy/mod.rs @@ -48,6 +48,8 @@ impl Container for Wire { // Legacy doesn't carry the keyframe bit on the wire; the // wrapping Consumer fills it in from group position. keyframe: false, + // Legacy carries no per-frame duration. + duration: None, }]))) } } diff --git a/rs/moq-mux/src/container/loc/mod.rs b/rs/moq-mux/src/container/loc/mod.rs index 0cd253a04..ae819ee8d 100644 --- a/rs/moq-mux/src/container/loc/mod.rs +++ b/rs/moq-mux/src/container/loc/mod.rs @@ -7,22 +7,32 @@ use std::task::Poll; -use crate::container::{Container, Frame, Timestamp}; +use crate::container::Timestamp; +use crate::container::{Container, Frame}; + +/// LOC's catalog convention: timestamps are in microseconds when no per-frame +/// 0x08 timescale property is present. +const DEFAULT_TIMESCALE: u64 = 1_000_000; /// LOC wire format. Each moq frame holds one LOC frame. #[derive(Default)] pub struct Wire; -const DEFAULT_TIMESCALE: u64 = 1_000_000; - impl Container for Wire { type Error = crate::Error; fn write(&self, group: &mut moq_net::GroupProducer, frames: &[Frame]) -> Result<(), Self::Error> { for frame in frames { - let data = moq_loc::encode(frame.timestamp.as_micros() as u64, &frame.payload)?; + // LOC's wire format omits per-frame timescale by convention; the catalog + // default is microseconds, so convert at the boundary. + let timestamp = frame.timestamp.convert::<1_000_000>().map_err(hang::Error::from)?; + let data = moq_loc::encode(timestamp.as_micros() as u64, &frame.payload)?; - let mut chunked = group.create_frame(data.len().into())?; + // Carry the timestamp on the net frame too (converted to the track's + // timescale), so a relay sees it without parsing the LOC payload. + let mut chunked = group.create_frame(moq_net::Frame { + size: data.len() as u64, + })?; chunked.write(data)?; chunked.finish()?; } @@ -41,8 +51,11 @@ impl Container for Wire { }; let loc = moq_loc::decode(data)?; - let timescale = loc.timescale.unwrap_or(DEFAULT_TIMESCALE); - let timestamp = Timestamp::from_scale(loc.timestamp, timescale).map_err(hang::Error::from)?; + // `loc.timescale == Some(0)` is a malformed wire (caught by moq_loc::decode itself), + // so any Some(_) we see here is non-zero. Falling back to the catalog default + // keeps this code path infallible. + let scale = loc.timescale.unwrap_or(DEFAULT_TIMESCALE); + let timestamp = Timestamp::from_scale(loc.timestamp, scale).map_err(hang::Error::from)?; Poll::Ready(Ok(Some(vec![Frame { timestamp, @@ -50,6 +63,8 @@ impl Container for Wire { // LOC doesn't carry the keyframe bit on the wire; the // wrapping Consumer fills it in from group position. keyframe: false, + // LOC carries no per-frame duration. + duration: None, }]))) } } diff --git a/rs/moq-mux/src/container/mkv/export.rs b/rs/moq-mux/src/container/mkv/export.rs index 054738b3f..6383a4951 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -3,16 +3,16 @@ use std::io::Cursor; use std::task::Poll; use std::time::Duration; -use anyhow::Context; use bytes::{BufMut, Bytes, BytesMut}; use hang::catalog::{AudioCodec, AudioConfig, Catalog, Container, VideoCodec, VideoConfig}; use webm_iterable::matroska_spec::{Master, MatroskaSpec}; use webm_iterable::{WebmWriter, WriteOptions}; -use crate::catalog::CatalogFormat; +use crate::Result; +use crate::catalog::Stream; +use crate::container::ExportSource; use crate::container::Frame; - -use crate::container::{CatalogSource, ExportSource}; +use crate::container::mkv::Error; /// Matroska TimestampScale: 1 ms (in nanoseconds). const TIMESTAMP_SCALE_NS: u64 = 1_000_000; @@ -44,9 +44,9 @@ const TIMESTAMP_SCALE_NS: u64 = 1_000_000; /// /// Only Legacy-container tracks (raw codec payloads) are supported. CMAF tracks /// (moof+mdat passthrough) are rejected with a clear error. -pub struct Export { +pub struct Export { broadcast: moq_net::BroadcastConsumer, - catalog: Option, + catalog: Option, latency: Duration, fragment_duration: Option, @@ -112,11 +112,11 @@ impl ClusterBuilder { keyframe: bool, payload: &[u8], is_video: bool, - ) -> anyhow::Result<()> { + ) -> Result<()> { let rel = (frame_ticks as i64) .checked_sub(self.start_ticks as i64) - .context("cluster underflow")?; - let rel: i16 = rel.try_into().context("block timestamp doesn't fit in i16")?; + .ok_or(Error::ClusterUnderflow)?; + let rel: i16 = rel.try_into().map_err(|_| Error::BlockTimestampOverflow)?; let sb_body = encode_simple_block_body(track_number, rel, keyframe, payload); write_tag_id(&mut self.body, ID_SIMPLEBLOCK as u32); @@ -151,29 +151,16 @@ impl ClusterBuilder { } } -impl Export { - /// Subscribe to `broadcast` and produce MKV byte chunks, using the default - /// catalog format ([`CatalogFormat::Hang`]). - /// - /// Use [`with_catalog_format`](Self::with_catalog_format) to subscribe to a - /// non-default catalog track (e.g. MSF). - pub fn new(broadcast: moq_net::BroadcastConsumer) -> Result { - Self::with_catalog_format(broadcast, CatalogFormat::default()) - } - - /// Subscribe to `broadcast` and produce MKV byte chunks, selecting an - /// explicit `catalog_format` for track discovery. +impl Export { + /// Subscribe to `broadcast` and produce MKV byte chunks, driving track + /// (un)subscription from `catalog`. /// - /// Both formats drive the same internal `hang::Catalog`-based pipeline (MSF - /// snapshots are converted on receipt), so the only observable difference - /// is which wire catalog track is consumed. - pub fn with_catalog_format( - broadcast: moq_net::BroadcastConsumer, - catalog_format: CatalogFormat, - ) -> Result { - let catalog = CatalogSource::new(&broadcast, catalog_format)?; - - Ok(Self { + /// `catalog` is any [`Stream`] of catalog snapshots, typically a + /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or wrapped in + /// [`catalog::Filter`](crate::catalog::Filter) / + /// [`catalog::Target`](crate::catalog::Target) to narrow the rendition set. + pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { + Self { broadcast, catalog: Some(catalog), latency: Duration::ZERO, @@ -182,7 +169,7 @@ impl Export { catalog_snapshot: None, header_emitted: false, cluster: None, - }) + } } /// Set the maximum buffering latency for each per-track source. @@ -207,11 +194,11 @@ impl Export { } /// Get the next byte chunk. - pub async fn next(&mut self) -> anyhow::Result> { + pub async fn next(&mut self) -> Result> { kio::wait(|waiter| self.poll_next(waiter)).await } - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { // 1. Drain catalog updates. while let Some(catalog) = self.catalog.as_mut() { match catalog.poll_next(waiter)? { @@ -310,7 +297,7 @@ impl Export { Poll::Pending } - fn update_catalog(&mut self, catalog: Catalog) -> anyhow::Result<()> { + fn update_catalog(&mut self, catalog: Catalog) -> Result<()> { let mut active: HashMap = HashMap::new(); for name in catalog.video.renditions.keys() { active.insert(name.clone(), ()); @@ -324,12 +311,12 @@ impl Export { if self.header_emitted { for name in active.keys() { if !self.tracks.contains_key(name) { - anyhow::bail!("MKV track layout changed after header was emitted: track '{name}' added"); + return Err(Error::HeaderAddedTrack(name.clone()).into()); } } for name in self.tracks.keys() { if !active.contains_key(name) { - anyhow::bail!("MKV track layout changed after header was emitted: track '{name}' removed"); + return Err(Error::HeaderRemovedTrack(name.clone()).into()); } } self.catalog_snapshot = Some(catalog); @@ -394,8 +381,8 @@ impl Export { && self.tracks.values().all(|t| t.source.header_ready()) } - fn build_header(&self) -> anyhow::Result { - let catalog = self.catalog_snapshot.as_ref().context("no catalog snapshot")?; + fn build_header(&self) -> Result { + let catalog = self.catalog_snapshot.as_ref().ok_or(Error::NoCatalogSnapshot)?; // Decide DocType: webm only if every codec is WebM-allowed. let webm_only = catalog @@ -412,7 +399,10 @@ impl Export { let mut entries: Vec = Vec::new(); for (name, config) in catalog.video.renditions.iter() { - let track = self.tracks.get(name).context("video track not subscribed")?; + let track = self + .tracks + .get(name) + .ok_or_else(|| Error::MissingVideoTrack(name.clone()))?; entries.push(build_video_track_entry( track.track_number, config, @@ -420,29 +410,40 @@ impl Export { )?); } for (name, config) in catalog.audio.renditions.iter() { - let track = self.tracks.get(name).context("audio track not subscribed")?; + let track = self + .tracks + .get(name) + .ok_or_else(|| Error::MissingAudioTrack(name.clone()))?; entries.push(build_audio_track_entry(track.track_number, config)?); } let mut dest = Cursor::new(Vec::new()); { let mut writer = WebmWriter::new(&mut dest); - writer.write(&MatroskaSpec::Ebml(Master::Full(vec![ - MatroskaSpec::DocType(doc_type.to_string()), - MatroskaSpec::DocTypeVersion(4), - MatroskaSpec::DocTypeReadVersion(2), - ])))?; - writer.write_advanced( - &MatroskaSpec::Segment(Master::Start), - WriteOptions::is_unknown_sized_element(), - )?; - writer.write(&MatroskaSpec::Info(Master::Full(vec![ - MatroskaSpec::TimestampScale(TIMESTAMP_SCALE_NS), - MatroskaSpec::MuxingApp("moq-mux".to_string()), - MatroskaSpec::WritingApp("moq-mux".to_string()), - ])))?; - writer.write(&MatroskaSpec::Tracks(Master::Full(entries)))?; - writer.flush()?; + writer + .write(&MatroskaSpec::Ebml(Master::Full(vec![ + MatroskaSpec::DocType(doc_type.to_string()), + MatroskaSpec::DocTypeVersion(4), + MatroskaSpec::DocTypeReadVersion(2), + ]))) + .map_err(Error::from)?; + writer + .write_advanced( + &MatroskaSpec::Segment(Master::Start), + WriteOptions::is_unknown_sized_element(), + ) + .map_err(Error::from)?; + writer + .write(&MatroskaSpec::Info(Master::Full(vec![ + MatroskaSpec::TimestampScale(TIMESTAMP_SCALE_NS), + MatroskaSpec::MuxingApp("moq-mux".to_string()), + MatroskaSpec::WritingApp("moq-mux".to_string()), + ]))) + .map_err(Error::from)?; + writer + .write(&MatroskaSpec::Tracks(Master::Full(entries))) + .map_err(Error::from)?; + writer.flush().map_err(Error::from)?; } Ok(Bytes::from(dest.into_inner())) @@ -460,15 +461,19 @@ impl Export { /// a chunk if the cluster rolled over (the returned chunk is the /// *previous* cluster; the new frame becomes the first block of a new /// open cluster). - fn feed_frame(&mut self, name: &str, frame: Frame) -> anyhow::Result> { - let track = self.tracks.get(name).context("missing track")?; + fn feed_frame(&mut self, name: &str, frame: Frame) -> Result> { + let track = self.tracks.get(name).ok_or(Error::MissingTrack)?; let track_number = track.track_number; let kind = track.kind; let payload = &frame.payload; - let frame_ticks: u64 = (frame.timestamp.as_micros() / 1_000) + // MKV's wire scale is ms (TIMESTAMP_SCALE_NS = 1_000_000). Re-express the + // frame's timestamp directly at MILLI rather than going through micros. + let frame_ticks: u64 = frame + .timestamp + .as_millis() .try_into() - .context("timestamp doesn't fit in u64 ms")?; + .map_err(|_| Error::TimestampU64)?; let is_video = kind == TrackKind::Video; let keyframe = frame.keyframe; @@ -508,14 +513,16 @@ impl Export { } } -fn ensure_legacy(container: &Container, kind: &str, name: &str) -> anyhow::Result<()> { +fn ensure_legacy(container: &Container, kind: &str, name: &str) -> Result<()> { match container { // MKV emits raw codec payloads, so it accepts both wire formats whose // frames are raw codec bitstreams (Legacy varint, LOC properties). Container::Legacy | Container::Loc => Ok(()), - Container::Cmaf { .. } => { - anyhow::bail!("MKV export does not support CMAF {} track '{}'", kind, name); + Container::Cmaf { .. } => Err(Error::UnsupportedCmafTrack { + kind: kind.to_string(), + name: name.to_string(), } + .into()), } } @@ -523,7 +530,7 @@ fn build_video_track_entry( track_number: u64, config: &VideoConfig, description: Option<&Bytes>, -) -> anyhow::Result { +) -> Result { // The description came from either the catalog (avc1/hvc1 sources) or // the codec transform (Avc3/Hev1 sources synthesizing it from inline params). let codec_private = description.map(|b| b.to_vec()); @@ -533,14 +540,14 @@ fn build_video_track_entry( VideoCodec::VP9(_) => ("V_VP9", None), VideoCodec::AV1(_) => ("V_AV1", codec_private), VideoCodec::H264(_) => { - let avcc = codec_private.context("H.264 track missing AVCDecoderConfigurationRecord")?; + let avcc = codec_private.ok_or(Error::MissingH264Avcc)?; ("V_MPEG4/ISO/AVC", Some(avcc)) } VideoCodec::H265(_) => { - let hvcc = codec_private.context("H.265 track missing HEVCDecoderConfigurationRecord")?; + let hvcc = codec_private.ok_or(Error::MissingH265Hvcc)?; ("V_MPEGH/ISO/HEVC", Some(hvcc)) } - other => anyhow::bail!("MKV export does not support video codec {:?}", other), + other => return Err(Error::UnsupportedVideoExport(format!("{:?}", other)).into()), }; let mut video_children: Vec = Vec::new(); @@ -567,7 +574,7 @@ fn build_video_track_entry( Ok(MatroskaSpec::TrackEntry(Master::Full(entry))) } -fn build_audio_track_entry(track_number: u64, config: &AudioConfig) -> anyhow::Result { +fn build_audio_track_entry(track_number: u64, config: &AudioConfig) -> Result { let (codec_id, codec_private) = match &config.codec { AudioCodec::Opus => ( "A_OPUS", @@ -586,11 +593,11 @@ fn build_audio_track_entry(track_number: u64, config: &AudioConfig) -> anyhow::R config .description .as_ref() - .context("AAC track missing AudioSpecificConfig (description)")? + .ok_or(Error::MissingAacDescription)? .to_vec(), ), ), - other => anyhow::bail!("MKV export does not support audio codec {:?}", other), + other => return Err(Error::UnsupportedAudioExport(format!("{:?}", other)).into()), }; let entry = vec![ diff --git a/rs/moq-mux/src/container/mkv/export_test.rs b/rs/moq-mux/src/container/mkv/export_test.rs index 20c4ab4f1..919ae54bb 100644 --- a/rs/moq-mux/src/container/mkv/export_test.rs +++ b/rs/moq-mux/src/container/mkv/export_test.rs @@ -23,12 +23,14 @@ async fn export_header_roundtrip_vp9_opus() { let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = crate::container::mkv::Import::new(producer, catalog.clone()); - let mut buf = bytes::BytesMut::from(import_bytes.as_slice()); - importer.decode(&mut buf).unwrap(); + let buf = bytes::BytesMut::from(import_bytes.as_slice()); + importer.decode(&buf).unwrap(); importer.finish().unwrap(); // Now subscribe via the exporter and pull bytes. - let mut exporter = crate::container::mkv::Export::new(consumer).unwrap(); + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = crate::container::mkv::Export::new(consumer, catalog_stream); // First `next()` should give us the header (EBML + Segment-start + Info + Tracks). let header = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) @@ -123,8 +125,8 @@ async fn export_header_roundtrip_vp9_opus() { let mut broadcast2 = moq_net::Broadcast::new().produce(); let catalog2 = crate::catalog::Producer::new(&mut broadcast2).unwrap(); let mut importer2 = crate::container::mkv::Import::new(broadcast2, catalog2.clone()); - let mut hbuf = bytes::BytesMut::from(header.as_ref()); - importer2.decode(&mut hbuf).unwrap(); + let hbuf = bytes::BytesMut::from(header.as_ref()); + importer2.decode(&hbuf).unwrap(); let snap = catalog2.snapshot(); assert_eq!(snap.video.renditions.len(), 1); assert_eq!(snap.audio.renditions.len(), 1); @@ -150,7 +152,8 @@ async fn export_waits_for_catalog_before_header() { // have been published yet: `tracks` stays empty on the first polls. let _catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let mut exporter = crate::container::mkv::Export::new(consumer).unwrap(); + let catalog_stream = crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).unwrap(); + let mut exporter = crate::container::mkv::Export::new(consumer, catalog_stream); // next() must remain pending (timing out), not surface a "no catalog // snapshot" error from a vacuously-ready empty track set. @@ -176,12 +179,13 @@ async fn export_emits_blocks_for_each_frame() { let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = crate::container::mkv::Import::new(producer, catalog.clone()); - let mut buf = bytes::BytesMut::from(import_bytes.as_slice()); - importer.decode(&mut buf).unwrap(); + let buf = bytes::BytesMut::from(import_bytes.as_slice()); + importer.decode(&buf).unwrap(); importer.finish().unwrap(); - let mut exporter = crate::container::mkv::Export::new(consumer) - .unwrap() + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = crate::container::mkv::Export::new(consumer, catalog_stream) // Use per-frame clustering so each frame is observable as its own // Cluster chunk; batching is exercised in a dedicated test below. .with_fragment_duration(std::time::Duration::ZERO); @@ -223,8 +227,8 @@ async fn export_emits_blocks_for_each_frame() { let mut bcast2 = moq_net::Broadcast::new().produce(); let cat2 = crate::catalog::Producer::new(&mut bcast2).unwrap(); let mut imp2 = crate::container::mkv::Import::new(bcast2, cat2.clone()); - let mut rt = bytes::BytesMut::from(exported.as_slice()); - imp2.decode(&mut rt).unwrap(); + let rt = bytes::BytesMut::from(exported.as_slice()); + imp2.decode(&rt).unwrap(); imp2.finish().unwrap(); let snap = cat2.snapshot(); assert_eq!(snap.video.renditions.len(), 1); @@ -250,7 +254,9 @@ async fn export_rejects_cmaf_track() { let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let track = producer.unique_track(".avc1").unwrap(); + let track = producer + .create_track(moq_net::Track::new(producer.unique_name(".avc1"))) + .unwrap(); let mut config = VideoConfig::new(H264 { profile: 0x64, constraints: 0, @@ -265,9 +271,11 @@ async fn export_rejects_cmaf_track() { timescale: None, track_id: None, }; - catalog.lock().video.renditions.insert(track.name.clone(), config); + catalog.lock().video.renditions.insert(track.name().to_string(), config); - let mut exporter = crate::container::mkv::Export::new(consumer).unwrap(); + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = crate::container::mkv::Export::new(consumer, catalog_stream); let result = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) .await .expect("exporter timed out"); @@ -290,7 +298,9 @@ async fn export_avc3_source_synthesizes_avcc_and_length_prefixes() { let consumer = producer.consume(); let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); - let track = producer.unique_track(".avc3").unwrap(); + let track = producer + .create_track(moq_net::Track::new(producer.unique_name(".avc3"))) + .unwrap(); let mut config = VideoConfig::new(H264 { profile: 0x42, constraints: 0xc0, @@ -300,7 +310,7 @@ async fn export_avc3_source_synthesizes_avcc_and_length_prefixes() { config.coded_width = Some(320); config.coded_height = Some(240); config.container = Container::Legacy; - catalog.lock().video.renditions.insert(track.name.clone(), config); + catalog.lock().video.renditions.insert(track.name().to_string(), config); // Annex-B start code. const SC: &[u8] = &[0, 0, 0, 1]; @@ -328,6 +338,7 @@ async fn export_avc3_source_synthesizes_avcc_and_length_prefixes() { timestamp: Timestamp::from_micros(0).unwrap(), payload: keyframe_payload, keyframe: true, + duration: None, }) .unwrap(); track_producer @@ -335,15 +346,17 @@ async fn export_avc3_source_synthesizes_avcc_and_length_prefixes() { timestamp: Timestamp::from_micros(33_000).unwrap(), payload: pslice_payload, keyframe: false, + duration: None, }) .unwrap(); track_producer.finish().unwrap(); let mut catalog = catalog; catalog.finish().unwrap(); - let mut exporter = crate::container::mkv::Export::new(consumer) - .unwrap() - .with_fragment_duration(std::time::Duration::ZERO); + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = + crate::container::mkv::Export::new(consumer, catalog_stream).with_fragment_duration(std::time::Duration::ZERO); let mut exported: Vec = Vec::new(); let mut held_producer = Some(producer); @@ -445,8 +458,8 @@ async fn export_avc3_source_synthesizes_avcc_and_length_prefixes() { let mut bcast2 = moq_net::Broadcast::new().produce(); let cat2 = crate::catalog::Producer::new(&mut bcast2).unwrap(); let mut imp2 = crate::container::mkv::Import::new(bcast2, cat2.clone()); - let mut rt = bytes::BytesMut::from(exported.as_slice()); - imp2.decode(&mut rt).unwrap(); + let rt = bytes::BytesMut::from(exported.as_slice()); + imp2.decode(&rt).unwrap(); imp2.finish().unwrap(); let snap = cat2.snapshot(); assert_eq!(snap.video.renditions.len(), 1); @@ -471,13 +484,14 @@ async fn export_fragment_duration_batches_blocks() { let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); let mut importer = crate::container::mkv::Import::new(producer, catalog.clone()); - let mut buf = bytes::BytesMut::from(import_bytes.as_slice()); - importer.decode(&mut buf).unwrap(); + let buf = bytes::BytesMut::from(import_bytes.as_slice()); + importer.decode(&buf).unwrap(); importer.finish().unwrap(); catalog.finish().unwrap(); - let mut exporter = crate::container::mkv::Export::new(consumer) - .unwrap() + let catalog_stream = + crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang).expect("catalog consumer"); + let mut exporter = crate::container::mkv::Export::new(consumer, catalog_stream) .with_fragment_duration(std::time::Duration::from_secs(2)); let mut exported: Vec = Vec::new(); diff --git a/rs/moq-mux/src/container/mkv/import.rs b/rs/moq-mux/src/container/mkv/import.rs index 75bb46566..4cfe6b72c 100644 --- a/rs/moq-mux/src/container/mkv/import.rs +++ b/rs/moq-mux/src/container/mkv/import.rs @@ -2,17 +2,18 @@ use std::collections::HashMap; use std::convert::TryFrom; use std::io::Cursor; -use crate::container::Timestamp; -use anyhow::Context; +use crate::Result; use bytes::{Buf, Bytes, BytesMut}; use hang::catalog::{AAC, AudioCodec, AudioConfig, Container, H264, H265, VP9, VideoCodec, VideoConfig}; use mp4_atom::Atom; -use tokio::io::{AsyncRead, AsyncReadExt}; use webm_iterable::WebmIterator; use webm_iterable::errors::TagIteratorError; use webm_iterable::iterator::AllowableErrors; use webm_iterable::matroska_spec::{Master, MatroskaSpec, SimpleBlock}; +use super::Error; +use crate::container::Timestamp; + /// Default Matroska TimestampScale: 1 ms (in nanoseconds). const DEFAULT_TIMESTAMP_SCALE_NS: u64 = 1_000_000; @@ -35,9 +36,9 @@ const DEFAULT_TIMESTAMP_SCALE_NS: u64 = 1_000_000; /// - Opus (`A_OPUS`) /// /// Unsupported codecs (e.g. Vorbis, AC3, MP3, subtitles) are logged and dropped. -pub struct Import { +pub struct Import { broadcast: moq_net::BroadcastProducer, - catalog: crate::catalog::Producer, + catalog: crate::catalog::Producer, /// Accumulated unparsed input. buffer: BytesMut, @@ -68,8 +69,8 @@ struct MkvTrack { last_emitted_ticks: Option, } -impl Import { - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { +impl Import { + pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { Self { broadcast, catalog, @@ -81,37 +82,14 @@ impl Import { } } - pub fn is_initialized(&self) -> bool { - self.tracks_seen - } - - /// Decode from an asynchronous reader. Drives [`Self::decode`] in a loop. - pub async fn decode_from(&mut self, reader: &mut T) -> anyhow::Result<()> { - let mut chunk = BytesMut::with_capacity(64 * 1024); - loop { - chunk.clear(); - let n = reader.read_buf(&mut chunk).await?; - if n == 0 { - break; - } - self.decode(&mut chunk)?; - } - Ok(()) - } - /// Append the buffer to the internal scratch and parse as many tags as possible. /// /// The buffer is fully consumed on every call (data is moved into the internal /// scratch). Bytes that cannot yet form a complete top-level tag are retained /// for the next call. - pub fn decode>(&mut self, buf: &mut T) -> anyhow::Result<()> { + pub fn decode(&mut self, data: &[u8]) -> Result<()> { // Move the input into our scratch buffer. - while buf.has_remaining() { - let chunk = buf.chunk(); - self.buffer.extend_from_slice(chunk); - let len = chunk.len(); - buf.advance(len); - } + self.buffer.extend_from_slice(data); self.drain() } @@ -123,7 +101,7 @@ impl Import { /// blocks). After parsing stops (UnexpectedEOF or end of buffer), bytes up to the start /// of the most-recently emitted top-level tag are discarded so memory does not grow /// unboundedly. - fn drain(&mut self) -> anyhow::Result<()> { + fn drain(&mut self) -> Result<()> { // Buffer master tags that are bounded and convenient to handle atomically. let buffered = [ MatroskaSpec::Ebml(Master::Start), @@ -160,8 +138,8 @@ impl Import { self.handle_tag(tag)?; } Some(Err(TagIteratorError::UnexpectedEOF { .. })) => break, - Some(Err(e)) => { - return Err(anyhow::Error::new(e).context("matroska parse error")); + Some(Err(_e)) => { + return Err(Error::MatroskaParse.into()); } None => { last_offset = snapshot.len(); @@ -182,7 +160,7 @@ impl Import { Ok(()) } - fn handle_tag(&mut self, tag: MatroskaSpec) -> anyhow::Result<()> { + fn handle_tag(&mut self, tag: MatroskaSpec) -> Result<()> { match tag { MatroskaSpec::Ebml(Master::Full(children)) => { self.handle_ebml(&children)?; @@ -214,7 +192,7 @@ impl Import { self.cluster_timestamp = v; } MatroskaSpec::SimpleBlock(ref data) => { - let sb = SimpleBlock::try_from(data.as_slice()).context("invalid SimpleBlock")?; + let sb = SimpleBlock::try_from(data.as_slice()).map_err(|_| Error::InvalidSimpleBlock)?; self.handle_block(sb.track, sb.timestamp, sb.keyframe, sb.raw_frame_data())?; } MatroskaSpec::BlockGroup(Master::Full(children)) => { @@ -226,19 +204,19 @@ impl Import { Ok(()) } - fn handle_ebml(&self, children: &[MatroskaSpec]) -> anyhow::Result<()> { + fn handle_ebml(&self, children: &[MatroskaSpec]) -> Result<()> { for c in children { if let MatroskaSpec::DocType(doc) = c { match doc.as_str() { "matroska" | "webm" => return Ok(()), - other => anyhow::bail!("unsupported EBML DocType: {}", other), + other => return Err(Error::UnsupportedDocType(other.to_string()).into()), } } } - anyhow::bail!("EBML header missing DocType"); + Err(Error::MissingDocType.into()) } - fn handle_tracks(&mut self, entries: Vec) -> anyhow::Result<()> { + fn handle_tracks(&mut self, entries: Vec) -> Result<()> { for entry in entries { if let MatroskaSpec::TrackEntry(Master::Full(children)) = entry { if let Err(e) = self.add_track(children) { @@ -249,7 +227,7 @@ impl Import { Ok(()) } - fn add_track(&mut self, children: Vec) -> anyhow::Result<()> { + fn add_track(&mut self, children: Vec) -> Result<()> { let mut track_number: Option = None; let mut track_type: Option = None; let mut codec_id: Option = None; @@ -269,9 +247,9 @@ impl Import { } } - let track_number = track_number.context("TrackEntry missing TrackNumber")?; - let track_type = track_type.context("TrackEntry missing TrackType")?; - let codec_id = codec_id.context("TrackEntry missing CodecID")?; + let track_number = track_number.ok_or(Error::MissingTrackNumber)?; + let track_type = track_type.ok_or(Error::MissingTrackType)?; + let codec_id = codec_id.ok_or(Error::MissingCodecId)?; // Matroska TrackType: 1 = video, 2 = audio. let (kind, suffix) = match track_type { @@ -283,18 +261,18 @@ impl Import { } }; - let net_track = self.broadcast.unique_track(suffix)?; + let track = self.broadcast.unique_track(suffix)?; let mut catalog = self.catalog.clone(); let mut catalog = catalog.lock(); match kind { TrackKind::Video => { let config = build_video_config(&codec_id, codec_private.as_ref(), video_children.as_deref())?; - catalog.video.renditions.insert(net_track.name.clone(), config); + catalog.video.renditions.insert(track.name().to_string(), config); } TrackKind::Audio => { let config = build_audio_config(&codec_id, codec_private.as_ref(), audio_children.as_deref())?; - catalog.audio.renditions.insert(net_track.name.clone(), config); + catalog.audio.renditions.insert(track.name().to_string(), config); } } @@ -304,7 +282,7 @@ impl Import { track_number, MkvTrack { kind, - track: crate::container::Producer::new(net_track, crate::catalog::hang::Container::Legacy), + track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), group: None, last_emitted_ticks: None, }, @@ -313,7 +291,7 @@ impl Import { Ok(()) } - fn handle_block_group(&mut self, children: &[MatroskaSpec]) -> anyhow::Result<()> { + fn handle_block_group(&mut self, children: &[MatroskaSpec]) -> Result<()> { let mut block_data: Option<&[u8]> = None; let mut has_reference = false; @@ -332,21 +310,24 @@ impl Import { // `Block` has the same on-wire header as `SimpleBlock` minus the keyframe flag. // We parse it via `SimpleBlock::try_from` (which works on the raw slice) but // derive keyframe from the absence of `ReferenceBlock`. - let parsed = SimpleBlock::try_from(data).context("invalid Block payload")?; + let parsed = SimpleBlock::try_from(data).map_err(|_| Error::InvalidBlock)?; let keyframe = !has_reference; self.handle_block(parsed.track, parsed.timestamp, keyframe, parsed.raw_frame_data()) } - fn handle_block(&mut self, track_number: u64, rel_ts: i16, keyframe: bool, payload: &[u8]) -> anyhow::Result<()> { + fn handle_block(&mut self, track_number: u64, rel_ts: i16, keyframe: bool, payload: &[u8]) -> Result<()> { let Some(track) = self.tracks.get_mut(&track_number) else { // Unknown or skipped track. return Ok(()); }; - // Compute PTS in nanoseconds, then convert to the Timestamp's microsecond timescale. + // Compute PTS in MKV's native nanosecond units and stamp it on the + // timestamp at NANO scale so a passthrough re-emit preserves precision. let block_ticks = (self.cluster_timestamp as i64) + (rel_ts as i64); - anyhow::ensure!(block_ticks >= 0, "negative block timestamp"); + if block_ticks < 0 { + return Err(Error::NegativeBlockTimestamp.into()); + } // Skip blocks we've already emitted on a previous decode() pass (buffer replay). if let Some(last) = track.last_emitted_ticks @@ -358,7 +339,7 @@ impl Import { let pts_ns = (block_ticks as u64) .checked_mul(self.timestamp_scale_ns) - .context("timestamp overflow")?; + .ok_or(Error::TimestampOverflow)?; let timestamp = Timestamp::from_nanos(pts_ns)?; // Audio tracks: always treat as keyframes (matches fmp4 behavior). @@ -368,6 +349,7 @@ impl Import { timestamp, payload: Bytes::copy_from_slice(payload), keyframe, + duration: None, }; // Manage groups: new group on video keyframe; audio always finishes its group immediately. @@ -393,7 +375,7 @@ impl Import { /// /// Broadcast-wide: every track inside this MKV import advances together; per-track /// control is intentionally not exposed. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { + pub fn seek(&mut self, sequence: u64) -> Result<()> { for track in self.tracks.values_mut() { track.track.seek(sequence)?; } @@ -401,7 +383,7 @@ impl Import { } /// Finish all tracks, flushing current groups. - pub fn finish(&mut self) -> anyhow::Result<()> { + pub fn finish(&mut self) -> Result<()> { for track in self.tracks.values_mut() { if let Some(mut g) = track.group.take() { g.finish()?; @@ -412,16 +394,16 @@ impl Import { } } -impl Drop for Import { +impl Drop for Import { fn drop(&mut self) { let mut catalog = self.catalog.lock(); for track in self.tracks.values() { match track.kind { TrackKind::Video => { - catalog.video.renditions.remove(&track.track.name); + catalog.video.renditions.remove(track.track.name()); } TrackKind::Audio => { - catalog.audio.renditions.remove(&track.track.name); + catalog.audio.renditions.remove(track.track.name()); } } } @@ -432,7 +414,7 @@ fn build_video_config( codec_id: &str, codec_private: Option<&Bytes>, video_children: Option<&[MatroskaSpec]>, -) -> anyhow::Result { +) -> Result { let (width, height) = video_children .map(|cs| { let mut w = None; @@ -475,7 +457,7 @@ fn build_video_config( "V_MPEG4/ISO/AVC" => build_h264_config(codec_private)?, "V_MPEGH/ISO/HEVC" => build_h265_config(codec_private)?, "V_AV1" => build_av1_config(codec_private)?, - other => anyhow::bail!("unsupported video CodecID: {}", other), + other => return Err(Error::UnsupportedVideoCodec(other.to_string()).into()), }; if config.coded_width.is_none() { @@ -492,7 +474,7 @@ fn build_audio_config( codec_id: &str, codec_private: Option<&Bytes>, audio_children: Option<&[MatroskaSpec]>, -) -> anyhow::Result { +) -> Result { let mut sample_rate: u32 = 0; let mut channels: u32 = 0; @@ -526,7 +508,10 @@ fn build_audio_config( Ok(config) } "A_AAC" => { - let priv_data = codec_private.context("A_AAC missing CodecPrivate (AudioSpecificConfig)")?; + let priv_data = codec_private.ok_or(Error::MissingCodecPrivate { + codec_id: "A_AAC", + purpose: "AudioSpecificConfig", + })?; let mut cursor = priv_data.clone(); let cfg = crate::codec::aac::Config::parse(&mut cursor)?; @@ -547,12 +532,15 @@ fn build_audio_config( config.container = Container::Legacy; Ok(config) } - other => anyhow::bail!("unsupported audio CodecID: {}", other), + other => Err(Error::UnsupportedAudioCodec(other.to_string()).into()), } } -fn build_h264_config(codec_private: Option<&Bytes>) -> anyhow::Result { - let avcc_bytes = codec_private.context("V_MPEG4/ISO/AVC missing CodecPrivate (AVCDecoderConfigurationRecord)")?; +fn build_h264_config(codec_private: Option<&Bytes>) -> Result { + let avcc_bytes = codec_private.ok_or(Error::MissingCodecPrivate { + codec_id: "V_MPEG4/ISO/AVC", + purpose: "AVCDecoderConfigurationRecord", + })?; let avcc = crate::codec::h264::Avcc::parse(avcc_bytes)?; let mut config = VideoConfig::new(H264 { @@ -568,10 +556,13 @@ fn build_h264_config(codec_private: Option<&Bytes>) -> anyhow::Result) -> anyhow::Result { - let hvcc_data = codec_private.context("V_MPEGH/ISO/HEVC missing CodecPrivate (HEVCDecoderConfigurationRecord)")?; +fn build_h265_config(codec_private: Option<&Bytes>) -> Result { + let hvcc_data = codec_private.ok_or(Error::MissingCodecPrivate { + codec_id: "V_MPEGH/ISO/HEVC", + purpose: "HEVCDecoderConfigurationRecord", + })?; let mut cursor = Cursor::new(hvcc_data.as_ref()); - let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).context("invalid HEVCDecoderConfigurationRecord")?; + let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).map_err(|_| Error::InvalidHvcc)?; let mut description = BytesMut::new(); hvcc.encode_body(&mut description)?; @@ -590,10 +581,13 @@ fn build_h265_config(codec_private: Option<&Bytes>) -> anyhow::Result) -> anyhow::Result { - let av1c_data = codec_private.context("V_AV1 missing CodecPrivate (AV1CodecConfigurationRecord)")?; +fn build_av1_config(codec_private: Option<&Bytes>) -> Result { + let av1c_data = codec_private.ok_or(Error::MissingCodecPrivate { + codec_id: "V_AV1", + purpose: "AV1CodecConfigurationRecord", + })?; let mut cursor = Cursor::new(av1c_data.as_ref()); - let av1c = mp4_atom::Av1c::decode_body(&mut cursor).context("invalid AV1CodecConfigurationRecord")?; + let av1c = mp4_atom::Av1c::decode_body(&mut cursor).map_err(|_| Error::InvalidAv1c)?; let mut description = BytesMut::new(); av1c.encode_body(&mut description)?; diff --git a/rs/moq-mux/src/container/mkv/import_test.rs b/rs/moq-mux/src/container/mkv/import_test.rs index c3e4b5521..f181c7194 100644 --- a/rs/moq-mux/src/container/mkv/import_test.rs +++ b/rs/moq-mux/src/container/mkv/import_test.rs @@ -138,8 +138,8 @@ fn run(data: &[u8]) -> crate::catalog::hang::Catalog { let mut broadcast = moq_net::Broadcast::new().produce(); let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut mkv = crate::container::mkv::Import::new(broadcast, catalog.clone()); - let mut buf = bytes::BytesMut::from(data); - mkv.decode(&mut buf).expect("decode"); + let buf = bytes::BytesMut::from(data); + mkv.decode(&buf).expect("decode"); mkv.finish().expect("finish"); catalog.snapshot() } @@ -228,8 +228,8 @@ fn test_chunked_decode_dedup() { // Feed in 16-byte chunks to stress the chunked-restart code path. for chunk in data.chunks(16) { - let mut b = bytes::BytesMut::from(chunk); - mkv.decode(&mut b).expect("decode chunk"); + let b = bytes::BytesMut::from(chunk); + mkv.decode(&b).expect("decode chunk"); } mkv.finish().expect("finish"); diff --git a/rs/moq-mux/src/container/mkv/mod.rs b/rs/moq-mux/src/container/mkv/mod.rs index 2bd85f43a..862b6c392 100644 --- a/rs/moq-mux/src/container/mkv/mod.rs +++ b/rs/moq-mux/src/container/mkv/mod.rs @@ -14,3 +14,112 @@ pub use import::*; mod export_test; #[cfg(test)] mod import_test; + +/// MKV parsing and emission errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("unsupported EBML DocType: {0}")] + UnsupportedDocType(String), + + #[error("EBML header missing DocType")] + MissingDocType, + + #[error("invalid SimpleBlock")] + InvalidSimpleBlock, + + #[error("invalid Block payload")] + InvalidBlock, + + #[error("negative block timestamp")] + NegativeBlockTimestamp, + + #[error("timestamp overflow")] + TimestampOverflow, + + #[error("TrackEntry missing TrackNumber")] + MissingTrackNumber, + + #[error("TrackEntry missing TrackType")] + MissingTrackType, + + #[error("TrackEntry missing CodecID")] + MissingCodecId, + + #[error("unsupported video CodecID: {0}")] + UnsupportedVideoCodec(String), + + #[error("unsupported audio CodecID: {0}")] + UnsupportedAudioCodec(String), + + #[error("{codec_id} missing CodecPrivate ({purpose})")] + MissingCodecPrivate { + codec_id: &'static str, + purpose: &'static str, + }, + + #[error("invalid HEVCDecoderConfigurationRecord")] + InvalidHvcc, + + #[error("invalid AV1CodecConfigurationRecord")] + InvalidAv1c, + + #[error("MKV track layout changed after header was emitted: track '{0}' added")] + HeaderAddedTrack(String), + + #[error("MKV track layout changed after header was emitted: track '{0}' removed")] + HeaderRemovedTrack(String), + + #[error("MKV export does not support CMAF {kind} track '{name}'")] + UnsupportedCmafTrack { kind: String, name: String }, + + #[error("MKV export does not support video codec {0}")] + UnsupportedVideoExport(String), + + #[error("MKV export does not support audio codec {0}")] + UnsupportedAudioExport(String), + + #[error("AAC track missing AudioSpecificConfig (description)")] + MissingAacDescription, + + #[error("H.264 track missing AVCDecoderConfigurationRecord")] + MissingH264Avcc, + + #[error("H.265 track missing HEVCDecoderConfigurationRecord")] + MissingH265Hvcc, + + #[error("cluster underflow")] + ClusterUnderflow, + + #[error("block timestamp doesn't fit in i16")] + BlockTimestampOverflow, + + #[error("missing track")] + MissingTrack, + + #[error("timestamp doesn't fit in u64 ms")] + TimestampU64, + + #[error("video track {0} missing in tracks map")] + MissingVideoTrack(String), + + #[error("audio track {0} missing in tracks map")] + MissingAudioTrack(String), + + #[error("no catalog snapshot")] + NoCatalogSnapshot, + + #[error("matroska parse error")] + MatroskaParse, + + #[error("matroska write error: {0}")] + MatroskaWrite(std::sync::Arc), +} + +impl From for Error { + fn from(err: webm_iterable::errors::TagWriterError) -> Self { + Error::MatroskaWrite(std::sync::Arc::new(err)) + } +} + +pub type Result = std::result::Result; diff --git a/rs/moq-mux/src/container/mod.rs b/rs/moq-mux/src/container/mod.rs index c1f9ae48e..913bdeb27 100644 --- a/rs/moq-mux/src/container/mod.rs +++ b/rs/moq-mux/src/container/mod.rs @@ -21,7 +21,6 @@ mod source; pub mod flv; pub mod fmp4; -pub mod hls; pub mod legacy; pub mod loc; pub mod mkv; @@ -29,10 +28,9 @@ pub mod ts; pub use consumer::Consumer; pub use producer::Producer; -pub(crate) use source::{CatalogSource, ExportSource}; +pub(crate) use source::ExportSource; -/// Microsecond presentation timestamp, the canonical timebase for media -/// frames in moq-mux. +/// Microsecond presentation timestamp, the canonical timebase for media frames in moq-mux on `main`. pub type Timestamp = moq_net::Timescale<1_000_000>; /// A decoded media frame: timestamp, payload bytes, keyframe flag. @@ -44,11 +42,24 @@ pub type Timestamp = moq_net::Timescale<1_000_000>; pub struct Frame { /// Presentation timestamp. /// - /// Microsecond precision. Frames within a track must be in *decode* - /// order, not display order. B-frames may have non-monotonic - /// presentation timestamps. + /// Each container picks its own native scale: fmp4 uses the source + /// `mdhd.timescale`, mkv uses nanoseconds, legacy is fixed at microseconds. + /// LOC defaults to microseconds but a decoded frame keeps whatever per-frame + /// timescale the wire carried, so an exporter can re-emit without forcing + /// micros. Frames within a track must be in *decode* order, not display + /// order. B-frames may have non-monotonic presentation timestamps. pub timestamp: Timestamp, + /// How long this frame occupies the presentation timeline, in the frame's + /// own scale, when the container reports it. + /// + /// CMAF carries a per-sample duration (trun sample-duration); containers + /// that don't (Legacy, LOC) leave this `None`. The [`Consumer`] adds it to + /// `timestamp` to learn how far a group has presented, so it can advance to + /// a newer group as soon as the gap is covered instead of waiting out the + /// latency budget. + pub duration: Option, + /// Encoded codec payload. pub payload: Bytes, @@ -62,6 +73,16 @@ pub struct Frame { pub keyframe: bool, } +/// A non-keyframe frame arrived with no open group. +/// +/// A track must open with a keyframe (and so must the frame after +/// [`finish_group`](Producer::finish_group) / [`seek`](Producer::seek)). +/// [`Producer::write`] returns this so a caller joining mid-stream can skip +/// frames until the first keyframe instead of treating it as fatal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("missing keyframe: a group must open on a keyframe")] +pub struct MissingKeyframe; + /// Encode and decode media frames over a moq-lite group. /// /// Implementors decide how many [`Frame`]s map onto one moq-lite frame: @@ -69,8 +90,9 @@ pub struct Frame { /// pack many samples into a single moof+mdat fragment. pub trait Container { /// Container-specific error. Must be convertible from [`moq_net::Error`] - /// so the IO layer's errors propagate cleanly. - type Error: std::error::Error + Send + Sync + Unpin + From; + /// (so IO errors propagate) and [`MissingKeyframe`] (so the producer can + /// reject a group that doesn't open on a keyframe). + type Error: std::error::Error + Send + Sync + Unpin + From + From; /// Encode one or more frames into a single moq-lite frame appended to `group`. fn write(&self, group: &mut moq_net::GroupProducer, frames: &[Frame]) -> Result<(), Self::Error>; diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index d7ffaeb92..fb9583c6e 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -1,4 +1,4 @@ -use super::{Container, Frame}; +use super::{Container, Frame, Timestamp}; /// A producer for media tracks that manages group boundaries. /// @@ -37,12 +37,6 @@ pub struct Producer { /// Sequence to use for the next group opened by [`Self::write`]. /// Set by [`Self::seek`] and consumed on the next group creation. pending_sequence: Option, - - /// When set, a non-keyframe arriving with no open group is dropped instead of - /// erroring. Lets a track join mid-stream (the leading deltas before the first - /// keyframe have no group to anchor) without a protocol violation. Off by - /// default so a producer that simply never marks keyframes still fails loudly. - lenient_start: bool, } impl Producer { @@ -55,18 +49,9 @@ impl Producer { buffer: Vec::new(), latency: std::time::Duration::ZERO, pending_sequence: None, - lenient_start: false, } } - /// Tolerate joining a stream mid-flight: drop non-keyframes that arrive before - /// the first keyframe (which has no group to anchor) instead of erroring. Use - /// for live ingest, where the input may start mid-GOP. - pub fn with_lenient_start(mut self) -> Self { - self.lenient_start = true; - self - } - /// The underlying moq-lite track producer. Read-only; mutating it directly /// would sidestep group/keyframe invariants. pub fn track(&self) -> &moq_net::TrackProducer { @@ -88,23 +73,21 @@ impl Producer { /// Write a frame to the track. /// /// A keyframe closes any open group and starts a new one. A non-keyframe extends - /// the current group; if no group is open it's a protocol violation, unless - /// [`with_lenient_start`](Self::with_lenient_start) was set (then it's dropped). + /// the current group; if no group is open it returns [`MissingKeyframe`](super::MissingKeyframe), + /// so a caller joining mid-stream can skip frames until the first keyframe. pub fn write(&mut self, frame: Frame) -> Result<(), C::Error> { - // Close the current group on an explicit keyframe. + // Close the current group on an explicit keyframe, passing its timestamp so + // the previous group's last frame can borrow it as a duration boundary. if frame.keyframe { - self.finish_group()?; + self.finish_group_at(Some(frame.timestamp))?; } // Start a new group if needed; the first frame of a group must be a keyframe. if self.group.is_none() { if !frame.keyframe { - // Mid-stream join: no group yet and this delta can't anchor one. Drop it - // (wait for the first keyframe) when lenient; otherwise it's a violation. - if self.lenient_start { - return Ok(()); - } - return Err(moq_net::Error::ProtocolViolation.into()); + // No group yet and this delta can't anchor one. The caller (e.g. a + // mid-stream join) decides whether to skip until the first keyframe. + return Err(super::MissingKeyframe.into()); } self.group = Some(match self.pending_sequence.take() { Some(sequence) => self.inner.create_group(moq_net::Group { sequence })?, @@ -119,13 +102,17 @@ impl Producer { } else { self.buffer.push(frame); - // Check if buffered duration exceeds latency. + // Flush if the buffered span has reached the latency budget. Compute + // min/max across the buffer rather than first/last: frames within a track + // are in *decode* order, and B-frames have non-monotonic PTS, so + // `last - first` can shrink as a B-frame lands between two earlier-PTS + // frames. The min/max pair captures the actual presentation span. if self.buffer.len() >= 2 { - let first_ts: std::time::Duration = self.buffer.first().unwrap().timestamp.into(); - let last_ts: std::time::Duration = self.buffer.last().unwrap().timestamp.into(); - - if last_ts.saturating_sub(first_ts) >= self.latency { - self.flush()?; + let mut iter = self.buffer.iter().map(|f| std::time::Duration::from(f.timestamp)); + let first = iter.next().unwrap(); + let (min, max) = iter.fold((first, first), |(min, max), d| (min.min(d), max.max(d))); + if max.saturating_sub(min) >= self.latency { + self.flush(None)?; } } } @@ -137,7 +124,14 @@ impl Producer { /// /// The next [`write`](Self::write) must be a keyframe. pub fn finish_group(&mut self) -> Result<(), C::Error> { - self.flush()?; + self.finish_group_at(None) + } + + /// Like [`finish_group`](Self::finish_group), but uses `next` (the timestamp of the + /// keyframe that rolled the group over) as the duration boundary for the group's + /// last frame. See [`flush`](Self::flush). + fn finish_group_at(&mut self, next: Option) -> Result<(), C::Error> { + self.flush(next)?; if let Some(mut group) = self.group.take() { group.finish()?; } @@ -155,11 +149,25 @@ impl Producer { } /// Flush any buffered frames into the current group without closing it. - fn flush(&mut self) -> Result<(), C::Error> { + /// + /// `next`, when given, is the timestamp of the frame that rolled the group over + /// (the next keyframe). The buffer's last frame is the only sample whose successor + /// wasn't visible when it arrived, so we backfill its duration from `next` here. + /// This adds no latency: that frame is already in hand. Containers that don't use + /// per-frame durations (Legacy, LOC) ignore it. + fn flush(&mut self, next: Option) -> Result<(), C::Error> { if self.buffer.is_empty() { return Ok(()); } + if let Some(next) = next + && let Some(last) = self.buffer.last_mut() + && last.duration.is_none() + && let Ok(duration) = next.checked_sub(last.timestamp) + { + last.duration = Some(duration); + } + let group = match &mut self.group { Some(group) => group, None => return Ok(()), @@ -200,11 +208,21 @@ mod tests { use crate::catalog::hang::Container; use crate::container::Timestamp; + /// Mint a standalone track for tests via a throwaway broadcast, since tracks are + /// born from their broadcast (no public `TrackProducer::new`). + fn track_producer(name: impl Into) -> moq_net::TrackProducer { + moq_net::Broadcast::new() + .produce() + .create_track(moq_net::Track::new(name)) + .unwrap() + } + fn frame(timestamp_us: u64, keyframe: bool) -> Frame { Frame { timestamp: Timestamp::from_micros(timestamp_us).unwrap(), payload: Bytes::from_static(&[0xDE, 0xAD]), keyframe, + duration: None, } } @@ -224,7 +242,7 @@ mod tests { /// Explicit keyframe closes the current group and starts a new one. #[tokio::test] async fn keyframe_closes_group_immediately() { - let track = moq_net::Track::new("test").produce(); + let track = track_producer("test"); let consumer = track.consume(); let mut producer = Producer::new(track, Container::Legacy); @@ -240,7 +258,7 @@ mod tests { /// `finish_group()` flushes the current group immediately; the next write must be a keyframe. #[tokio::test] async fn finish_group_closes_immediately() { - let track = moq_net::Track::new("test").produce(); + let track = track_producer("test"); let consumer = track.consume(); let mut producer = Producer::new(track, Container::Legacy); @@ -253,14 +271,14 @@ mod tests { assert_eq!(collect_groups(consumer).await, vec![2, 1]); } - /// Writing a non-keyframe with no open group is a protocol violation. + /// Writing a non-keyframe with no open group returns MissingKeyframe. #[test] fn first_frame_must_be_keyframe() { - let track = moq_net::Track::new("test").produce(); + let track = track_producer("test"); let mut producer = Producer::new(track, Container::Legacy); let err = producer.write(frame(0, false)).unwrap_err(); - assert!(matches!(err, crate::Error::Moq(moq_net::Error::ProtocolViolation))); + assert!(matches!(err, crate::Error::MissingKeyframe(_))); } /// Drain all groups from a finished track, returning their sequence numbers. @@ -275,7 +293,7 @@ mod tests { /// `seek(n)` opens the next group at sequence `n`. #[tokio::test] async fn seek_uses_explicit_sequence() { - let track = moq_net::Track::new("test").produce(); + let track = track_producer("test"); let consumer = track.consume(); let mut producer = Producer::new(track, Container::Legacy); @@ -290,7 +308,7 @@ mod tests { /// `seek` is consumed on the next group creation; subsequent groups auto-increment from there. #[tokio::test] async fn seek_clears_pending_after_use() { - let track = moq_net::Track::new("test").produce(); + let track = track_producer("test"); let consumer = track.consume(); let mut producer = Producer::new(track, Container::Legacy); @@ -301,4 +319,48 @@ mod tests { assert_eq!(collect_sequences(consumer).await, vec![5, 6]); } + + /// Records the frames handed to each `write`, so tests can inspect the + /// durations the producer backfilled. Write-only. + #[derive(Clone, Default)] + struct Recording(std::rc::Rc>>>); + + impl super::Container for Recording { + type Error = crate::Error; + + fn write(&self, _group: &mut moq_net::GroupProducer, frames: &[Frame]) -> Result<(), Self::Error> { + self.0.borrow_mut().push(frames.to_vec()); + Ok(()) + } + + fn poll_read( + &self, + _group: &mut moq_net::GroupConsumer, + _waiter: &kio::Waiter, + ) -> std::task::Poll>, Self::Error>> { + unreachable!("Recording is write-only") + } + } + + /// The keyframe that rolls a group over backfills the duration of the previous + /// group's last frame, without buffering an extra frame. + #[tokio::test] + async fn keyframe_backfills_last_frame_duration() { + let track = track_producer("test"); + let recording = Recording::default(); + let mut producer = Producer::new(track, recording.clone()).with_latency(std::time::Duration::from_secs(10)); + + producer.write(frame(0, true)).unwrap(); // group 0 opens + producer.write(frame(33_000, false)).unwrap(); // buffered + producer.write(frame(66_000, true)).unwrap(); // rolls group 0 over -> flush with next = 66ms + producer.finish().unwrap(); + + let writes = recording.0.borrow(); + let group0 = &writes[0]; + assert_eq!(group0.len(), 2); + // Last frame's duration backfilled from the next keyframe: 66ms - 33ms. + assert_eq!(group0[1].duration, Some(Timestamp::from_micros(33_000).unwrap())); + // The earlier frame keeps None; only the trailing sample needs the boundary. + assert_eq!(group0[0].duration, None); + } } diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index fd0f10c06..74da75aa1 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -13,66 +13,17 @@ //! existing `description` (for already-out-of-band sources) or the synthesized //! avcC/hvcC (for Annex-B sources). -use std::task::{Poll, ready}; +use std::task::Poll; use std::time::Duration; use bytes::Bytes; use hang::catalog::{AudioConfig, VideoCodec, VideoConfig}; -use crate::catalog::CatalogFormat; use crate::catalog::hang::Container as HangContainer; -use crate::catalog::hang::{Catalog, CatalogExt}; use crate::codec::h264::Avc1; use crate::codec::h265::Hvc1; use crate::container::{Consumer, Frame}; -/// Source for the catalog stream backing an exporter. -/// -/// Both variants yield [`Catalog`]; MSF is media-only, so its extension is -/// always the empty default. -pub(crate) enum CatalogSource { - /// The hang catalog track (track name `catalog.json`, JSON payload). - Hang(crate::catalog::hang::Consumer), - /// The MSF catalog track (track name `catalog`, MSF JSON payload converted to hang). - Msf(crate::catalog::msf::Consumer), -} - -impl CatalogSource { - pub(crate) fn new(broadcast: &moq_net::BroadcastConsumer, format: CatalogFormat) -> Result { - Ok(match format { - CatalogFormat::Hang => { - let track = broadcast.subscribe_track(&hang::Catalog::default_track())?; - CatalogSource::Hang(crate::catalog::hang::Consumer::new(track)) - } - CatalogFormat::HangZ => { - let track = broadcast.subscribe_track(&hang::Catalog::compressed_track())?; - CatalogSource::Hang(crate::catalog::hang::Consumer::compressed(track)) - } - CatalogFormat::Msf => { - let track = broadcast.subscribe_track(&moq_net::Track::new(moq_msf::DEFAULT_NAME))?; - CatalogSource::Msf(crate::catalog::msf::Consumer::new(track)) - } - }) - } - - pub(crate) fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { - match self { - Self::Hang(c) => { - let catalog = ready!(c.poll_next(waiter))?; - Poll::Ready(Ok(catalog)) - } - Self::Msf(c) => { - let catalog = ready!(c.poll_next(waiter))?; - Poll::Ready(Ok(catalog.map(|media| Catalog { - video: media.video, - audio: media.audio, - ext: E::default(), - }))) - } - } - } -} - /// Per-track video transform that bridges between codec shapes. pub(crate) enum VideoTransform { Avc1(Avc1), @@ -87,19 +38,26 @@ impl VideoTransform { } } - fn transform(&mut self, payload: Bytes) -> anyhow::Result> { + fn transform(&mut self, payload: Bytes) -> crate::Result> { match self { - VideoTransform::Avc1(t) => t.transform(payload), - VideoTransform::Hvc1(t) => t.transform(payload), + VideoTransform::Avc1(t) => Ok(t.transform(payload)?), + VideoTransform::Hvc1(t) => Ok(t.transform(payload)?), } } } +/// A subscription that resolves on first poll, then the live consumer. +enum SourceState { + /// The resolved consumer, reading frames. Boxed because it's much larger than + /// a lightweight subscription handle. + Active(Box>), +} + /// A per-rendition source that normalizes frame shape (Annex-B → /// length-prefixed for H.264/H.265) and exposes the resolved codec config /// record alongside the frame stream. pub(crate) struct ExportSource { - consumer: Consumer, + state: SourceState, transform: Option, /// Resolved codec configuration record (avcC / hvcC / AudioSpecificConfig / /// OpusHead). Some once the codec config is available — from the catalog @@ -116,19 +74,40 @@ impl ExportSource { latency: Duration, ) -> Result { let media: HangContainer = (&config.container).try_into()?; - let track = broadcast.subscribe_track(&moq_net::Track::new(name.to_string()))?; - let consumer = Consumer::new(track, media).with_latency(latency); - let transform = build_video_transform(config); let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); Ok(Self { - consumer, + state: SourceState::Active(Box::new( + Consumer::new(broadcast.subscribe_track(&moq_net::Track::new(name))?, media).with_latency(latency), + )), transform, description, }) } + /// Subscribe to a video rendition without attaching any codec-shape + /// transform. Payloads pass through untouched (Annex-B stays Annex-B, + /// avc1 length-prefixed stays length-prefixed). The Annex-B exporter + /// uses this to keep parameter sets in-band. + pub fn for_video_raw( + broadcast: &moq_net::BroadcastConsumer, + name: &str, + config: &VideoConfig, + latency: Duration, + ) -> Result { + let media: HangContainer = (&config.container).try_into()?; + let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); + + Ok(Self { + state: SourceState::Active(Box::new( + Consumer::new(broadcast.subscribe_track(&moq_net::Track::new(name))?, media).with_latency(latency), + )), + transform: None, + description, + }) + } + /// Subscribe to an audio rendition. Audio has no codec-shape transform; /// `description` is taken straight from the catalog. pub fn for_audio( @@ -138,12 +117,12 @@ impl ExportSource { latency: Duration, ) -> Result { let media: HangContainer = (&config.container).try_into()?; - let track = broadcast.subscribe_track(&moq_net::Track::new(name.to_string()))?; - let consumer = Consumer::new(track, media).with_latency(latency); let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); Ok(Self { - consumer, + state: SourceState::Active(Box::new( + Consumer::new(broadcast.subscribe_track(&moq_net::Track::new(name))?, media).with_latency(latency), + )), transform: None, description, }) @@ -157,11 +136,14 @@ impl ExportSource { name: &str, latency: Duration, ) -> Result { - let track = broadcast.subscribe_track(&moq_net::Track::new(name.to_string()))?; - let consumer = Consumer::new(track, HangContainer::Legacy).with_latency(latency); - Ok(Self { - consumer, + state: SourceState::Active(Box::new( + Consumer::new( + broadcast.subscribe_track(&moq_net::Track::new(name))?, + HangContainer::Legacy, + ) + .with_latency(latency), + )), transform: None, description: None, }) @@ -183,13 +165,18 @@ impl ExportSource { /// Parameter-only frames (SPS/PPS-only inputs to the Avc3 transform) are /// absorbed and the next frame is polled. Returns `Ready(None)` at /// end-of-track. - pub fn poll_read(&mut self, waiter: &kio::Waiter) -> Poll>> { + pub fn poll_read(&mut self, waiter: &kio::Waiter) -> Poll>> { loop { - let frame = match self.consumer.poll_read(waiter) { - Poll::Ready(Ok(Some(f))) => f, - Poll::Ready(Ok(None)) => return Poll::Ready(Ok(None)), - Poll::Ready(Err(e)) => return Poll::Ready(Err(e.into())), - Poll::Pending => return Poll::Pending, + // Scope the consumer borrow to the poll so `self.transform` / + // `self.refresh_description` can borrow `self` afterwards. + let frame = { + let SourceState::Active(consumer) = &mut self.state; + match consumer.poll_read(waiter) { + Poll::Ready(Ok(Some(f))) => f, + Poll::Ready(Ok(None)) => return Poll::Ready(Ok(None)), + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, + } }; let Some(transform) = self.transform.as_mut() else { diff --git a/rs/moq-mux/src/container/ts/catalog.rs b/rs/moq-mux/src/container/ts/catalog.rs index 0fb4506d5..6e2a1014d 100644 --- a/rs/moq-mux/src/container/ts/catalog.rs +++ b/rs/moq-mux/src/container/ts/catalog.rs @@ -164,8 +164,8 @@ impl Catalog for () { } } -// The untyped catalog extension doesn't carry a typed `mpegts` section, so verbatim MPEG-TS -// carriage is disabled for it (same as `()`). Use [`Ext`] to record MPEG-TS details. +// The untyped passthrough carries no typed mpegts section (a TS importer driving an `Extra` +// catalog records verbatim streams as raw JSON sections, not the typed `Mpegts` view). impl Catalog for crate::catalog::hang::Extra { fn mpegts_mut(&mut self) -> Option<&mut Mpegts> { None diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index 623ad03bc..92832f876 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -1,9 +1,10 @@ //! MPEG-TS muxer. //! -//! [`Export`] subscribes to a MoQ broadcast and produces a single MPEG-TS byte -//! stream: PAT/PMT program tables followed by one PES packet per media frame, -//! packetized into 188-byte TS packets. Video is carried as Annex-B, audio as -//! ADTS AAC. +//! [`Export`] subscribes to a MoQ broadcast and produces MPEG-TS, yielding one +//! [`Frame`] per media frame: PAT/PMT program tables followed by one PES packet, +//! packetized into 188-byte TS packets. Each frame keeps its media timestamp so +//! the caller can pace delivery on the media clock. Video is carried as Annex-B, +//! audio as ADTS AAC. //! //! Video flows through [`ExportSource`], which normalizes every H.264/H.265 //! source to length-prefixed NALU plus a resolved avcC/hvcC (parsing in-band @@ -28,10 +29,10 @@ use mpeg2ts::ts::{ TsHeader, TsPacket, TsPacketWriter, TsPayload, VersionNumber, WriteTsPacket, }; -use crate::catalog::CatalogFormat; use crate::catalog::hang::Catalog; +use crate::catalog::{CatalogFormat, Stream}; use crate::codec::annexb; -use crate::container::{CatalogSource, ExportSource, Frame}; +use crate::container::{ExportSource, Frame, Timestamp}; use super::adts; use super::catalog; @@ -46,14 +47,13 @@ const PSI_INTERVAL: Duration = Duration::from_millis(500); /// Subscribe to a broadcast and produce an MPEG-TS byte stream. /// /// Use [`next`](Self::next) to pull one [`Frame`] per media frame: its `payload` -/// is the TS packets, stamped with the source `timestamp` and `keyframe` flag so -/// a transport can pace delivery on the media clock. The leading PAT/PMT rides on -/// the first frame (so it inherits a real timestamp), and is re-emitted at video -/// keyframes and periodically for mid-stream tune-in. Returns `None` when the -/// broadcast ends. +/// is the TS packets, stamped with the source `timestamp` and `keyframe` flag. +/// The leading PAT/PMT rides on the first frame (so it inherits a real +/// timestamp), and is re-emitted at video keyframes and periodically for +/// mid-stream tune-in. Returns `None` when the broadcast ends. pub struct Export { broadcast: moq_net::BroadcastConsumer, - catalog: Option>, + catalog: Option>, latency: Duration, tracks: HashMap, @@ -65,7 +65,7 @@ pub struct Export { /// Program tables, built once the track layout is known. psi: Option, /// Media timestamp of the last PAT/PMT emission. - last_psi: Option, + last_psi: Option, /// Tune-in point: the first video keyframe's timestamp, captured when the program /// tables are built. Non-video frames before it are dropped so the keyframe leads /// the stream. @@ -77,7 +77,7 @@ pub struct Export { /// sets behind an audio-only preamble, and a live decoder probing the stream gives /// up before it ever configures video. `None` until the tables are built, and for /// programs with no video track (nothing to align to). - video_start: Option, + video_start: Option, } struct Track { @@ -138,7 +138,7 @@ struct PesUnit { is_pcr: bool, is_video: bool, keyframe: bool, - timestamp: crate::container::Timestamp, + timestamp: Timestamp, /// Authored decode timestamp for a reordered (B-frame) video frame, in continuous /// (unwrapped) 90 kHz ticks (wrapped to the wire field in `write_pes`). `Some` only when /// it differs from the PTS; the PES then carries both PTS and DTS. @@ -177,7 +177,7 @@ impl Export { /// Shared constructor. The public entry points each live on a concrete /// `Export` impl that pins `E`, so the extension is chosen by which one you call. fn build(broadcast: moq_net::BroadcastConsumer, catalog_format: CatalogFormat) -> Result { - let catalog = CatalogSource::new(&broadcast, catalog_format)?; + let catalog = crate::catalog::Consumer::::new(&broadcast, catalog_format)?; Ok(Self { broadcast, catalog: Some(catalog), @@ -204,14 +204,11 @@ impl Export { /// transport can pace delivery on the media clock. The leading PAT/PMT rides /// on the first frame (inheriting its timestamp), and is re-emitted at video /// keyframes and periodically for mid-stream tune-in. Returns `None` when the - /// broadcast ends. + /// broadcast ends. `duration` is always `None`: the muxer has no use for it. pub async fn next(&mut self) -> anyhow::Result> { kio::wait(|waiter| self.poll_next(waiter)).await } - /// Poll for the next muxed frame, driving the underlying sources via `waiter`. - /// The `Poll::Ready` counterpart of [`next`](Self::next), for synchronous or - /// custom executors. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>> { // 1. Drain catalog updates, discovering the track layout. while let Some(catalog) = self.catalog.as_mut() { @@ -258,7 +255,7 @@ impl Export { track.finished = true; break; } - Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Ready(Err(e)) => return Poll::Ready(Err(e.into())), Poll::Pending => break, } } @@ -498,7 +495,7 @@ impl Export { /// The smallest timestamp among the video tracks' buffered frames: the first /// video keyframe, since pre-keyframe video frames are dropped before the tables /// are built. `None` when no video track has a buffered frame (audio-only program). - fn first_video_pts(&self) -> Option { + fn first_video_pts(&self) -> Option { self.tracks .values() .filter(|t| matches!(t.kind, Kind::Video(_))) @@ -625,6 +622,7 @@ impl Export { Ok(()) } + /// Name of the track whose pending frame has the smallest timestamp. fn pick_next_track(&self) -> Option { self.tracks .iter() @@ -633,8 +631,10 @@ impl Export { .map(|(n, _)| n) } - /// Packetize one media frame into a chunk, re-emitting PAT/PMT before video - /// keyframes (and periodically) so receivers can tune in mid-stream. + /// Packetize one media frame into an output [`Frame`], re-emitting PAT/PMT + /// before video keyframes (and periodically) so receivers can tune in + /// mid-stream. The returned frame keeps the source `timestamp` and `keyframe` + /// flag so the caller can pace it. fn write_frame(&mut self, name: &str, frame: Frame) -> anyhow::Result { let track = self.tracks.get(name).context("missing track")?; let pid = track.pid; @@ -725,6 +725,7 @@ impl Export { } Ok(Frame { timestamp, + duration: None, payload: Bytes::from(out), keyframe, }) @@ -899,8 +900,8 @@ const PES_DTS_LEN: usize = 5; /// [`author_dts`] and [`Track::dts_reserve`]. const DEFAULT_DTS_RESERVE: u64 = 16; -fn psi_interval() -> crate::container::Timestamp { - crate::container::Timestamp::try_from(PSI_INTERVAL).unwrap_or(crate::container::Timestamp::ZERO) +fn psi_interval() -> Timestamp { + Timestamp::try_from(PSI_INTERVAL).unwrap_or(Timestamp::ZERO) } /// External byte size of an adaptation field (manual mirror of the crate's @@ -915,11 +916,11 @@ const TS_TIMESTAMP_MASK: u64 = (1 << 33) - 1; /// Continuous (unwrapped) 90 kHz tick count for a media timestamp. The decode clock runs in /// this domain so it never wraps mid-stream (the source timestamps are already unwrapped); /// [`to_ts_timestamp`] masks to the 33-bit wire field only at emission. -fn to_ticks(timestamp: crate::container::Timestamp) -> u64 { +fn to_ticks(timestamp: Timestamp) -> u64 { (timestamp.as_micros() * 90_000 / 1_000_000) as u64 } -fn to_ts_timestamp(timestamp: crate::container::Timestamp) -> anyhow::Result { +fn to_ts_timestamp(timestamp: Timestamp) -> anyhow::Result { // Continuous 90 kHz ticks, wrapped into the 33-bit field. TsTimestamp::new(to_ticks(timestamp) & TS_TIMESTAMP_MASK).map_err(anyhow::Error::msg) } @@ -1046,7 +1047,7 @@ fn author_dts(pts: u64, reserve: u64, last: &mut Option) -> Option { fn dts_reserve(config: &VideoConfig) -> u64 { config .jitter - .map(|t| t.as_scale(90_000) as u64) + .map(|t| (t.as_micros() * 90_000 / 1_000_000) as u64) .filter(|&ticks| ticks > 0) .unwrap_or(DEFAULT_DTS_RESERVE) } diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 478eabd23..689b12299 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -15,8 +15,9 @@ use mpeg2ts::pes::{PesPacketReader, ReadPesPacket}; use mpeg2ts::ts::{ReadTsPacket, TsPacketReader, TsPayload}; use crate::catalog::hang::Container as HangContainer; +use crate::container::Timestamp; use crate::container::ts::{Export, catalog as tscat}; -use crate::container::{Frame, Producer, Timestamp}; +use crate::container::{Frame, Producer}; const SC: &[u8] = &[0, 0, 0, 1]; // Reusable H.264 parameter-set and slice NALs (NAL type = first byte & 0x1f). @@ -90,8 +91,10 @@ async fn export_aac_roundtrip() { let consumer = broadcast.consume(); let mut catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); - let track = broadcast.unique_track(".aac").unwrap(); - let name = track.name.clone(); + let track = broadcast + .create_track(moq_net::Track::new(broadcast.unique_name(".aac"))) + .unwrap(); + let name = track.name().to_string(); { let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2); cfg.container = Container::Legacy; @@ -108,7 +111,8 @@ async fn export_aac_roundtrip() { for (i, payload) in frames.iter().enumerate() { producer .write(Frame { - timestamp: Timestamp::from_millis(i as u64 * 20).unwrap(), + timestamp: Timestamp::from_micros(i as u64 * 20_000).unwrap(), + duration: None, payload: payload.clone(), keyframe: true, }) @@ -200,8 +204,9 @@ async fn export_lead_audio() -> BytesMut { let mut catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); // In-band avc3 video (SPS/PPS inline on keyframes; no out-of-band description). - let vtrack = broadcast.unique_track(".avc3").unwrap(); - let vname = vtrack.name.clone(); + let vtrack = broadcast + .create_track(moq_net::Track::new(broadcast.unique_name(".avc3"))) + .unwrap(); { let mut cfg = VideoConfig::new(H264 { profile: 0x42, @@ -210,21 +215,23 @@ async fn export_lead_audio() -> BytesMut { inline: true, }); cfg.container = Container::Legacy; - catalog.lock().video.renditions.insert(vname, cfg); + catalog.lock().video.renditions.insert(vtrack.name().to_string(), cfg); } let mut video = Producer::new(vtrack, HangContainer::Legacy); - let atrack = broadcast.unique_track(".aac").unwrap(); - let aname = atrack.name.clone(); + let atrack = broadcast + .create_track(moq_net::Track::new(broadcast.unique_name(".aac"))) + .unwrap(); { let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2); cfg.container = Container::Legacy; - catalog.lock().audio.renditions.insert(aname, cfg); + catalog.lock().audio.renditions.insert(atrack.name().to_string(), cfg); } let mut audio = Producer::new(atrack, HangContainer::Legacy); let audio_frame = |ms: u64| Frame { - timestamp: Timestamp::from_millis(ms).unwrap(), + timestamp: Timestamp::from_micros(ms * 1_000).unwrap(), + duration: None, payload: Bytes::from(vec![0xAAu8; 16]), keyframe: true, }; @@ -237,7 +244,8 @@ async fn export_lead_audio() -> BytesMut { idr.extend(std::iter::repeat_n(0xAB, 200)); video .write(Frame { - timestamp: Timestamp::from_millis(100).unwrap(), + timestamp: Timestamp::from_micros(100_000).unwrap(), + duration: None, payload: annexb(&[SPS, PPS, &idr]), keyframe: true, }) @@ -329,8 +337,10 @@ async fn export_avc3_in_band_reassembles() { let consumer = broadcast.consume(); let mut catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); - let track = broadcast.unique_track(".avc3").unwrap(); - let name = track.name.clone(); + let track = broadcast + .create_track(moq_net::Track::new(broadcast.unique_name(".avc3"))) + .unwrap(); + let name = track.name().to_string(); { let mut cfg = VideoConfig::new(H264 { profile: 0x64, @@ -349,7 +359,8 @@ async fn export_avc3_in_band_reassembles() { // Annex-B keyframe: inline SPS + PPS + IDR. producer .write(Frame { - timestamp: Timestamp::from_millis(0).unwrap(), + timestamp: Timestamp::from_micros(0).unwrap(), + duration: None, payload: annexb(&[SPS, PPS, &idr]), keyframe: true, }) @@ -374,8 +385,10 @@ async fn export_avc3_preserves_multiple_pps() { let consumer = broadcast.consume(); let mut catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); - let track = broadcast.unique_track(".avc3").unwrap(); - let name = track.name.clone(); + let track = broadcast + .create_track(moq_net::Track::new(broadcast.unique_name(".avc3"))) + .unwrap(); + let name = track.name().to_string(); { let mut cfg = VideoConfig::new(H264 { profile: 0x64, @@ -394,6 +407,7 @@ async fn export_avc3_preserves_multiple_pps() { producer .write(Frame { timestamp: Timestamp::from_millis(0).unwrap(), + duration: None, payload: annexb(&[SPS, PPS, PPS1, &idr]), keyframe: true, }) @@ -421,8 +435,10 @@ async fn export_avc1_out_of_band_reassembles() { let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap(); - let track = broadcast.unique_track(".avc1").unwrap(); - let name = track.name.clone(); + let track = broadcast + .create_track(moq_net::Track::new(broadcast.unique_name(".avc1"))) + .unwrap(); + let name = track.name().to_string(); { let mut cfg = VideoConfig::new(H264 { profile: 0x64, @@ -442,7 +458,8 @@ async fn export_avc1_out_of_band_reassembles() { // Length-prefixed keyframe: just the slice, no inline parameter sets. producer .write(Frame { - timestamp: Timestamp::from_millis(0).unwrap(), + timestamp: Timestamp::from_micros(0).unwrap(), + duration: None, payload: length_prefixed(&[&idr]), keyframe: true, }) @@ -471,7 +488,7 @@ async fn export_bframe_video_authors_dts() { let consumer = broadcast.consume(); let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - import.decode(&mut BytesMut::from(&data[..])).unwrap(); + import.decode(&BytesMut::from(&data[..])).unwrap(); import.finish().unwrap(); // `import` and `catalog` stay alive: retained tracks the exporter subscribes to. @@ -545,7 +562,7 @@ async fn export_scte35_roundtrip() { // `Import` (which consumes it); the producer stays alive so the exporter can // subscribe to the retained track. let scte = broadcast.unique_track(".scte35").unwrap(); - let scte_name = scte.name.clone(); + let scte_name = scte.name().to_string(); { let track = tscat::Track { pid: 0x102, @@ -556,10 +573,11 @@ async fn export_scte35_roundtrip() { } let mut scte_producer = Producer::new(scte, HangContainer::Legacy); // bbb's first video keyframe is at 1.4 s; stamp the cue just after it so it survives - // the muxer's keyframe-aligned tune-in (which drops non-video frames before the keyframe). + // the tune-in alignment (a cue before the first keyframe is dropped with the lead). scte_producer .write(Frame { timestamp: Timestamp::from_millis(1410).unwrap(), + duration: None, payload: Bytes::from_static(CUE), keyframe: true, }) @@ -569,11 +587,11 @@ async fn export_scte35_roundtrip() { // Now add the real video/audio by importing bbb.ts (this moves `broadcast`). let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - import.decode(&mut BytesMut::from(&data[..])).unwrap(); + import.decode(&BytesMut::from(&data[..])).unwrap(); import.finish().unwrap(); // `import`, `catalog`, and `scte_producer` stay alive: retained tracks. The - // exporter must carry the extension to see the ts section. + // exporter must carry the extension to see the mpegts section. let ts = drain_with(Export::with_ts(consumer, crate::catalog::CatalogFormat::Hang).unwrap()).await; assert_packet_aligned(&ts); @@ -606,7 +624,7 @@ async fn export_scte35_roundtrip() { crate::catalog::Producer::with_catalog(&mut broadcast2, crate::catalog::hang::Catalog::::default()) .unwrap(); let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.clone()); - import2.decode(&mut BytesMut::from(ts.as_ref())).unwrap(); + import2.decode(&BytesMut::from(ts.as_ref())).unwrap(); import2.finish().unwrap(); let snapshot = catalog2.snapshot(); @@ -614,7 +632,7 @@ async fn export_scte35_roundtrip() { assert_eq!(verbatim, 1, "round-trip lost the SCTE-35 track"); let name = scte_track(&snapshot).expect("a scte35 track"); - let track = consumer2.subscribe_track(&moq_net::Track::new(name.clone())).unwrap(); + let track = consumer2.subscribe_track(&moq_net::Track::new(name.as_str())).unwrap(); let mut scte_reader = crate::container::Consumer::new(track, HangContainer::Legacy); let frame = scte_reader .read() @@ -650,7 +668,7 @@ async fn export_pes_verbatim_roundtrip() { // Build the verbatim PES track BEFORE moving `broadcast` into `Import`; the // producer stays alive so the exporter can subscribe to the retained track. let data_track = broadcast.unique_track(".data").unwrap(); - let data_name = data_track.name.clone(); + let data_name = data_track.name().to_string(); { let mut verbatim = tscat::Verbatim::new(0x06, tscat::Framing::Pes); verbatim.stream_id = Some(STREAM_ID); @@ -660,10 +678,11 @@ async fn export_pes_verbatim_roundtrip() { } let mut data_producer = Producer::new(data_track, HangContainer::Legacy); // bbb's first video keyframe is at 1.4 s; stamp the PES just after it so it survives - // the muxer's keyframe-aligned tune-in (which drops non-video frames before the keyframe). + // the tune-in alignment (content before the first keyframe is dropped with the lead). data_producer .write(Frame { timestamp: Timestamp::from_millis(1410).unwrap(), + duration: None, payload: Bytes::from_static(PAYLOAD), keyframe: true, }) @@ -673,7 +692,7 @@ async fn export_pes_verbatim_roundtrip() { // Real video/audio supplies the media clock (moves `broadcast`). let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - import.decode(&mut BytesMut::from(&data[..])).unwrap(); + import.decode(&BytesMut::from(&data[..])).unwrap(); import.finish().unwrap(); // `import`, `catalog`, and `data_producer` stay alive: retained tracks. @@ -687,7 +706,7 @@ async fn export_pes_verbatim_roundtrip() { crate::catalog::Producer::with_catalog(&mut broadcast2, crate::catalog::hang::Catalog::::default()) .unwrap(); let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.clone()); - import2.decode(&mut BytesMut::from(ts.as_ref())).unwrap(); + import2.decode(&BytesMut::from(ts.as_ref())).unwrap(); import2.finish().unwrap(); let snapshot = catalog2.snapshot(); @@ -703,7 +722,7 @@ async fn export_pes_verbatim_roundtrip() { assert_eq!(verbatim.stream_id, Some(STREAM_ID), "PES stream_id preserved"); let name = name.clone(); - let track = consumer2.subscribe_track(&moq_net::Track::new(name)).unwrap(); + let track = consumer2.subscribe_track(&moq_net::Track::new(name.as_str())).unwrap(); let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy); let frame = reader .read() @@ -729,7 +748,7 @@ async fn scte35_without_video_export_is_rejected() { // A SCTE-35 cue track and nothing else. let scte = broadcast.unique_track(".scte35").unwrap(); - let scte_name = scte.name.clone(); + let scte_name = scte.name().to_string(); { let track = tscat::Track { pid: 0x102, @@ -742,6 +761,7 @@ async fn scte35_without_video_export_is_rejected() { producer .write(Frame { timestamp: Timestamp::from_millis(0).unwrap(), + duration: None, payload: Bytes::from_static(CUE), keyframe: true, }) @@ -766,9 +786,7 @@ async fn scte35_without_video_export_is_rejected() { /// Subscribe to a track and read every retained frame payload it holds. async fn read_frames(consumer: &moq_net::BroadcastConsumer, name: &str) -> Vec> { - let track = consumer - .subscribe_track(&moq_net::Track::new(name.to_string())) - .unwrap(); + let track = consumer.subscribe_track(&moq_net::Track::new(name)).unwrap(); let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy); let mut frames = Vec::new(); while let Ok(res) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await { @@ -780,7 +798,9 @@ async fn read_frames(consumer: &moq_net::BroadcastConsumer, name: &str) -> Vec MoQ -> TS byte-for-byte, and /// the PMT must re-announce them as MPEG-1 audio (0x03): the capture is 48 kHz, -/// so the half-rate type (0x04) would be unfaithful. +/// so the half-rate type (0x04) would be unfaithful. This capture is a dirty start +/// (begins mid-GOP), so the export's keyframe alignment drops the MP2 ahead of the +/// first video keyframe; what remains is a byte-exact suffix of each program. #[tokio::test(start_paused = true)] async fn mp2_kyrion_roundtrip_byte_exact() { let data = include_bytes!("test_data/scte35/kyrion_dirtystart.ts"); @@ -789,7 +809,7 @@ async fn mp2_kyrion_roundtrip_byte_exact() { let consumer = broadcast.consume(); let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - import.decode(&mut BytesMut::from(&data[..])).unwrap(); + import.decode(&BytesMut::from(&data[..])).unwrap(); import.finish().unwrap(); let names: Vec = catalog.snapshot().audio.renditions.keys().cloned().collect(); @@ -825,7 +845,7 @@ async fn mp2_kyrion_roundtrip_byte_exact() { let consumer2 = broadcast2.consume(); let catalog2 = crate::catalog::Producer::new(&mut broadcast2).unwrap(); let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.clone()); - import2.decode(&mut BytesMut::from(ts.as_ref())).unwrap(); + import2.decode(&BytesMut::from(ts.as_ref())).unwrap(); import2.finish().unwrap(); let names2: Vec = catalog2.snapshot().audio.renditions.keys().cloned().collect(); @@ -858,7 +878,7 @@ async fn ac3_roundtrip_byte_exact() { let consumer = broadcast.consume(); let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - import.decode(&mut BytesMut::from(&data[..])).unwrap(); + import.decode(&BytesMut::from(&data[..])).unwrap(); import.finish().unwrap(); let name = catalog @@ -902,7 +922,7 @@ async fn ac3_roundtrip_byte_exact() { let consumer2 = broadcast2.consume(); let catalog2 = crate::catalog::Producer::new(&mut broadcast2).unwrap(); let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.clone()); - import2.decode(&mut BytesMut::from(ts.as_ref())).unwrap(); + import2.decode(&BytesMut::from(ts.as_ref())).unwrap(); import2.finish().unwrap(); let name2 = catalog2 @@ -928,7 +948,7 @@ async fn eac3_roundtrip_byte_exact() { let consumer = broadcast.consume(); let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - import.decode(&mut BytesMut::from(&data[..])).unwrap(); + import.decode(&BytesMut::from(&data[..])).unwrap(); import.finish().unwrap(); let name = catalog @@ -975,7 +995,7 @@ async fn eac3_roundtrip_byte_exact() { let consumer2 = broadcast2.consume(); let catalog2 = crate::catalog::Producer::new(&mut broadcast2).unwrap(); let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.clone()); - import2.decode(&mut BytesMut::from(ts.as_ref())).unwrap(); + import2.decode(&BytesMut::from(ts.as_ref())).unwrap(); import2.finish().unwrap(); let name2 = catalog2 @@ -1015,7 +1035,7 @@ async fn kyrion_ac3_mp2_roundtrip_byte_exact() { let consumer = broadcast.consume(); let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - import.decode(&mut BytesMut::from(&data[..])).unwrap(); + import.decode(&BytesMut::from(&data[..])).unwrap(); import.finish().unwrap(); let ingested = read_audio_by_codec(&consumer, &catalog).await; @@ -1063,7 +1083,7 @@ async fn kyrion_ac3_mp2_roundtrip_byte_exact() { let consumer2 = broadcast2.consume(); let catalog2 = crate::catalog::Producer::new(&mut broadcast2).unwrap(); let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.clone()); - import2.decode(&mut BytesMut::from(ts.as_ref())).unwrap(); + import2.decode(&BytesMut::from(ts.as_ref())).unwrap(); import2.finish().unwrap(); let roundtripped = read_audio_by_codec(&consumer2, &catalog2).await; @@ -1082,9 +1102,7 @@ fn scte_track(snap: &crate::catalog::hang::Catalog) -> Option Vec<(Vec, Timestamp)> { - let track = consumer - .subscribe_track(&moq_net::Track::new(name.to_string())) - .unwrap(); + let track = consumer.subscribe_track(&moq_net::Track::new(name)).unwrap(); let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy); let mut cues = Vec::new(); while let Ok(res) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await { @@ -1158,7 +1176,7 @@ async fn scte35_fixtures_survive_roundtrip() { ) .unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - import.decode(&mut BytesMut::from(&data[..])).unwrap(); + import.decode(&BytesMut::from(&data[..])).unwrap(); import.finish().unwrap(); let snap = catalog.snapshot(); @@ -1209,7 +1227,7 @@ async fn scte35_fixtures_survive_roundtrip() { ) .unwrap(); let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.clone()); - import2.decode(&mut BytesMut::from(ts.as_ref())).unwrap(); + import2.decode(&BytesMut::from(ts.as_ref())).unwrap(); import2.finish().unwrap(); let name2 = scte_track(&catalog2.snapshot()).expect("a scte35 track"); let roundtripped = read_cues(&consumer2, &name2).await; diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index f6feea0fd..cb19727a3 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -14,12 +14,11 @@ use std::collections::{HashMap, HashSet}; use std::io::Read; use std::sync::{Arc, Mutex}; -use bytes::{Buf, BytesMut}; +use bytes::BytesMut; use mpeg2ts::es::StreamType; use mpeg2ts::pes::PesHeader; use mpeg2ts::ts::payload::Pes; use mpeg2ts::ts::{Pid, ReadTsPacket, TsPacket, TsPacketReader, TsPayload}; -use tokio::io::{AsyncRead, AsyncReadExt}; use super::adts; use super::catalog; @@ -128,35 +127,11 @@ impl Import { } } - /// True once the stream layout (PMT) has been discovered. - pub fn is_initialized(&self) -> bool { - self.initialized - } - - /// Decode from an asynchronous reader, driving [`Self::decode`] in a loop. - pub async fn decode_from(&mut self, reader: &mut T) -> anyhow::Result<()> { - let mut chunk = BytesMut::with_capacity(64 * 1024); - loop { - chunk.clear(); - let n = reader.read_buf(&mut chunk).await?; - if n == 0 { - break; - } - self.decode(&mut chunk)?; - } - Ok(()) - } - /// Append `buf` to the internal scratch and demux every whole TS packet it /// now completes. The buffer is fully consumed; a trailing partial packet /// (< 188 bytes) is retained for the next call. - pub fn decode>(&mut self, buf: &mut T) -> anyhow::Result<()> { - while buf.has_remaining() { - let chunk = buf.chunk(); - self.scratch.extend_from_slice(chunk); - let len = chunk.len(); - buf.advance(len); - } + pub fn decode(&mut self, data: &[u8]) -> anyhow::Result<()> { + self.scratch.extend_from_slice(data); // Route one whole packet at a time. Section-framed verbatim PIDs are // intercepted here (the reader would PES-parse their sections and abort); @@ -318,17 +293,21 @@ impl Import { let stream = match stream_type { StreamType::H264 => { - let import = - h264::Import::new(self.broadcast.clone(), self.catalog.clone()).with_mode(h264::Mode::Avc3)?; + let track = crate::import::unique_track(&mut self.broadcast, ".avc3")?; Stream::H264 { - import: Box::new(import), + split: h264::Split::new(), + import: Box::new(h264::Import::new(track, self.catalog.clone())), + unwrap: PtsUnwrap::default(), + } + } + StreamType::H265 => { + let track = crate::import::unique_track(&mut self.broadcast, ".hev1")?; + Stream::H265 { + split: h265::Split::new(), + import: Box::new(h265::Import::new(track, self.catalog.clone())), unwrap: PtsUnwrap::default(), } } - StreamType::H265 => Stream::H265 { - import: Box::new(h265::Import::new(self.broadcast.clone(), self.catalog.clone())), - unwrap: PtsUnwrap::default(), - }, // Only ADTS-framed AAC (0x0F). 0x11 is LATM/LOAS, which uses a different // framing and syncword, so it falls through to the ignored arm below. StreamType::AdtsAac => Stream::Aac(Box::new(AacStream { @@ -619,6 +598,9 @@ fn register_verbatim( framing: catalog::Framing, descriptors: Vec, ) -> anyhow::Result> { + // Verbatim payloads ride the legacy container, which normalizes the per-frame + // timestamp to microseconds on the wire (see `hang::container::Frame::encode`), + // so the track declares that timescale to match. let track = broadcast.unique_track(".ts")?; let mut guard = catalog.lock(); @@ -628,7 +610,7 @@ fn register_verbatim( anyhow::bail!("catalog extension no longer carries an mpegts section"); }; mpegts.tracks.insert( - track.name.clone(), + track.name().to_string(), catalog::Track { pid, descriptors, @@ -702,6 +684,7 @@ impl SectionStream { fn emit(&mut self, section: Vec, pts: Timestamp) -> anyhow::Result<()> { let frame = crate::container::Frame { timestamp: pts, + duration: None, payload: bytes::Bytes::from(section), keyframe: true, }; @@ -723,7 +706,7 @@ impl SectionStream { impl Drop for SectionStream { fn drop(&mut self) { - let name = self.track.name.clone(); + let name = self.track.name().to_string(); unregister_verbatim(&mut self.catalog, &name); } } @@ -772,7 +755,7 @@ impl VerbatimStream { // Record the original PES stream_id once, from the first PES, so export // re-emits the stream under its real id (e.g. 0xBD for teletext/DVB AC-3). if !self.stream_id_recorded { - let name = self.track.name.clone(); + let name = self.track.name().to_string(); if let Some(mpegts) = self.catalog.lock().mpegts_mut() && let Some(verbatim) = mpegts.tracks.get_mut(&name).and_then(|t| t.verbatim.as_mut()) { @@ -784,6 +767,7 @@ impl VerbatimStream { let pts = unwrap_pts(&mut self.unwrap, pending.pts)?.unwrap_or(Timestamp::ZERO); let frame = crate::container::Frame { timestamp: pts, + duration: None, payload: bytes::Bytes::from(pending.data), keyframe: true, }; @@ -805,7 +789,7 @@ impl VerbatimStream { impl Drop for VerbatimStream { fn drop(&mut self) { - let name = self.track.name.clone(); + let name = self.track.name().to_string(); unregister_verbatim(&mut self.catalog, &name); } } @@ -964,10 +948,12 @@ impl SectionReassembler { /// One elementary stream's codec importer plus PTS-unwrap state. enum Stream { H264 { + split: h264::Split, import: Box>, unwrap: PtsUnwrap, }, H265 { + split: h265::Split, import: Box>, unwrap: PtsUnwrap, }, @@ -984,20 +970,30 @@ enum Stream { impl Stream { fn write(&mut self, pending: Pending, burst: Option) -> anyhow::Result<()> { match self { - Stream::H264 { import, unwrap } => { + Stream::H264 { split, import, unwrap } => { let reorder = reorder_delay(pending.pts, pending.dts); let pts = unwrap_pts(unwrap, pending.pts)?; - import.decode_frame(&mut pending.data.as_slice(), pts)?; - // After decode_frame, so the track (and its catalog rendition) exists. + skip_missing_keyframe((|| { + // Each PES is one access unit, so flush to emit it immediately. + let mut frames = split.decode(&pending.data, pts)?; + frames.extend(split.flush(pts)?); + import.decode(frames) + })())?; + // After decode, so the track (and its catalog rendition) exists. if let Some(reorder) = reorder { import.observe_reorder(reorder); } Ok(()) } - Stream::H265 { import, unwrap } => { + Stream::H265 { split, import, unwrap } => { let reorder = reorder_delay(pending.pts, pending.dts); let pts = unwrap_pts(unwrap, pending.pts)?; - import.decode_frame(&mut pending.data.as_slice(), pts)?; + skip_missing_keyframe((|| { + // Each PES is one access unit, so flush to emit it immediately. + let mut frames = split.decode(&pending.data, pts)?; + frames.extend(split.flush(pts)?); + import.decode(frames) + })())?; if let Some(reorder) = reorder { import.observe_reorder(reorder); } @@ -1012,8 +1008,14 @@ impl Stream { fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { match self { - Stream::H264 { import, .. } => import.seek(sequence), - Stream::H265 { import, .. } => import.seek(sequence), + Stream::H264 { split, import, .. } => { + split.reset(); + Ok(import.seek(sequence)?) + } + Stream::H265 { split, import, .. } => { + split.reset(); + Ok(import.seek(sequence)?) + } Stream::Aac(stream) => stream.seek(sequence), Stream::Legacy(stream) => stream.seek(sequence), Stream::Verbatim(stream) => stream.seek(sequence), @@ -1023,8 +1025,8 @@ impl Stream { fn finish(&mut self) -> anyhow::Result<()> { match self { - Stream::H264 { import, .. } => import.finish(), - Stream::H265 { import, .. } => import.finish(), + Stream::H264 { import, .. } => Ok(import.finish()?), + Stream::H265 { import, .. } => Ok(import.finish()?), Stream::Aac(stream) => stream.finish(), Stream::Legacy(stream) => stream.finish(), Stream::Verbatim(stream) => stream.finish(), @@ -1036,9 +1038,9 @@ impl Stream { /// exists. `None` for verbatim/clock/ignored streams (verbatim self-registers). fn media_track_name(&self) -> Option { match self { - Stream::H264 { import, .. } => import.track().map(|t| t.name.clone()), - Stream::H265 { import, .. } => import.track().ok().map(|t| t.name.clone()), - Stream::Aac(stream) => stream.import.as_ref().map(|i| i.track().name.clone()), + Stream::H264 { import, .. } => Some(import.name().to_string()), + Stream::H265 { import, .. } => Some(import.name().to_string()), + Stream::Aac(stream) => stream.import.as_ref().map(|i| i.name().to_string()), Stream::Legacy(stream) => stream.import.as_ref().map(|i| i.name().to_string()), Stream::Verbatim(_) | Stream::Clock | Stream::Ignored => None, } @@ -1084,12 +1086,10 @@ impl AacStream { // downstream consumers that need out-of-band config (fMP4/MKV export, // WebCodecs) can configure the decoder. TS itself carries it inline. let description = config.encode(); - let import = aac::Import::new(self.broadcast.clone(), self.catalog.clone(), config)?; - let name = import.track().name.clone(); - if let Some(rendition) = self.catalog.lock().audio.renditions.get_mut(&name) { - rendition.description = Some(description); - } - self.import.insert(import) + let track = crate::import::unique_track(&mut self.broadcast, ".aac")?; + let mut aac = aac::Import::new(track, self.catalog.clone(), config)?; + aac.update_rendition(|rendition| rendition.description = Some(description)); + self.import.insert(aac) } }; @@ -1102,8 +1102,7 @@ impl AacStream { other => other, }; - let mut raw = &data[offset + header.header_len..end]; - import.decode(&mut raw, pts)?; + import.decode(&data[offset + header.header_len..end], pts)?; offset = end; index += 1; @@ -1145,11 +1144,10 @@ impl AacStream { } self.jitter = Some(jitter); - if let Some(import) = &self.import { - let name = import.track().name.clone(); - if let Some(rendition) = self.catalog.lock().audio.renditions.get_mut(&name) { - rendition.jitter = Some(jitter.convert()?); - } + if let Some(import) = &mut self.import { + import.update_rendition(|rendition| { + rendition.jitter = moq_net::Time::from_scale(jitter.as_micros() as u64, 1_000_000).ok(); + }); } Ok(()) } @@ -1235,14 +1233,13 @@ impl LegacyStream { sample_rate: header.sample_rate, channel_count: header.channel_count, }; - let import = - legacy::Import::new(self.descriptor, self.broadcast.clone(), self.catalog.clone(), config)?; - self.import.insert(import) + let track = crate::import::unique_track(&mut self.broadcast, self.descriptor.track_suffix)?; + let legacy = legacy::Import::new(self.descriptor, track, self.catalog.clone(), config); + self.import.insert(legacy) } }; - let mut frame = &data[offset..end]; - import.decode(&mut frame, pts)?; + import.decode(&data[offset..end], pts)?; pts = match pts { Some(pts) => Some(pts + Timestamp::from_scale(header.samples, header.sample_rate as u64)?), @@ -1305,6 +1302,16 @@ fn pes_data_len(header: &PesHeader, pes_packet_len: u16) -> Option { /// Convert a raw 90 kHz PTS to a microsecond [`Timestamp`], unwrapping the /// 33-bit field. Returns `None` when the PES carried no PTS (the codec layer /// then falls back to a wall-clock timestamp). +/// Swallow a [`MissingKeyframe`](crate::container::MissingKeyframe) from a video +/// decode: a TS capture can join mid-GOP, so the deltas before the first keyframe +/// have no group to anchor and are simply dropped rather than aborting the demux. +fn skip_missing_keyframe(result: crate::Result<()>) -> anyhow::Result<()> { + match result { + Ok(()) | Err(crate::Error::MissingKeyframe(_)) => Ok(()), + Err(e) => Err(e.into()), + } +} + fn unwrap_pts(unwrap: &mut PtsUnwrap, pts: Option) -> anyhow::Result> { let Some(raw) = pts else { return Ok(None); @@ -1611,7 +1618,7 @@ mod test { } // An extended catalog detects the CUEI PID, advertises a cue track, and the - // section is published (a `Catalog` carries the rendition). + // section is published (a `Catalog` carries the rendition). #[test] fn scte35_extension_catalogs_the_cue_track() { use crate::catalog::hang::Catalog; @@ -1624,7 +1631,7 @@ mod test { let mut bytes = bytes::BytesMut::new(); bytes.extend_from_slice(&synth_pmt(&[(StreamType::Dts8ChannelLosslessAudio, 0x21)], true)); bytes.extend_from_slice(&packet(true, 0, 0, &CUE)); - import.decode(&mut bytes).unwrap(); + import.decode(&bytes).unwrap(); import.finish().unwrap(); assert_eq!( @@ -1647,7 +1654,7 @@ mod test { let mut bytes = bytes::BytesMut::new(); bytes.extend_from_slice(&synth_pmt(&[(StreamType::Dts8ChannelLosslessAudio, 0x21)], true)); bytes.extend_from_slice(&packet(true, 0, 0, &CUE)); - import.decode(&mut bytes).unwrap(); // must not abort on the private section + import.decode(&bytes).unwrap(); // must not abort on the private section import.finish().unwrap(); assert!( @@ -1694,7 +1701,7 @@ mod test { &[(StreamType::Dts8ChannelLosslessAudio, SECTION_PID)], false, )); - import.decode(&mut bytes).unwrap(); + import.decode(&bytes).unwrap(); assert!( matches!(import.streams.get(&pid), Some(super::Stream::Ignored)), "pre-CUEI PMT routes the PID to Ignored" @@ -1704,7 +1711,7 @@ mod test { let mut bytes = bytes::BytesMut::new(); bytes.extend_from_slice(&synth_pmt(&[(StreamType::Dts8ChannelLosslessAudio, SECTION_PID)], true)); bytes.extend_from_slice(&packet(true, 0, 0, &CUE)); - import.decode(&mut bytes).unwrap(); + import.decode(&bytes).unwrap(); import.finish().unwrap(); assert!( @@ -1718,7 +1725,7 @@ mod test { ); let name = catalog.snapshot().mpegts.tracks.keys().next().unwrap().clone(); - let track = consumer.subscribe_track(&moq_net::Track::new(name)).unwrap(); + let track = consumer.subscribe_track(&moq_net::Track::new(name.as_str())).unwrap(); let mut reader = Consumer::new(track, Container::Legacy).with_latency(std::time::Duration::ZERO); let frame = tokio::time::timeout(std::time::Duration::from_secs(1), reader.read()) .await @@ -1778,19 +1785,19 @@ mod test { ], true, )); - import.decode(&mut bytes).unwrap(); + import.decode(&bytes).unwrap(); // Private before video: no clock yet. - import.decode(&mut pes_packet(PRIVATE_PID, 1_000).as_slice()).unwrap(); + import.decode(pes_packet(PRIVATE_PID, 1_000).as_slice()).unwrap(); assert!(import.last_pts.is_none(), "a private PES must not start the clock"); // Video sets the clock. - import.decode(&mut pes_packet(VIDEO_PID, 90_000).as_slice()).unwrap(); + import.decode(pes_packet(VIDEO_PID, 90_000).as_slice()).unwrap(); let after_video = import.last_pts; assert!(after_video.is_some(), "MPEG-2 video PTS must set the clock"); // Private after video: must NOT overwrite it. - import.decode(&mut pes_packet(PRIVATE_PID, 270_000).as_slice()).unwrap(); + import.decode(pes_packet(PRIVATE_PID, 270_000).as_slice()).unwrap(); assert_eq!( import.last_pts, after_video, "a later private PES must not overwrite the clock" @@ -1860,7 +1867,7 @@ mod test { aac.extend_from_slice(&[0u8; 8]); bytes.extend_from_slice(&audio_pes_packet(AAC_PID, 0, 270_000, &aac)); - import.decode(&mut bytes).unwrap(); + import.decode(&bytes).unwrap(); import.finish().unwrap(); let snap = catalog.snapshot(); @@ -1871,11 +1878,11 @@ mod test { .values() .find(|a| a.codec.to_string().starts_with("mp4a")) .expect("AAC rendition"); - let jitter = aac_rendition.jitter.expect("AAC publishes a jitter"); + let jitter = std::time::Duration::from(aac_rendition.jitter.expect("AAC publishes a jitter")); // Anchored on its own PES: one 1024-sample frame at 48 kHz (~21 ms). // Anchored on the MP2 PES it would be ~2 s. assert!( - jitter <= moq_net::Time::from_millis_unchecked(100), + jitter <= std::time::Duration::from_millis(100), "AAC jitter anchored on a foreign PID: {jitter:?}" ); } @@ -1893,7 +1900,7 @@ mod test { .next() .expect("an audio track") .clone(); - let track = consumer.subscribe_track(&moq_net::Track::new(name)).unwrap(); + let track = consumer.subscribe_track(&moq_net::Track::new(name.as_str())).unwrap(); let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy); let mut frames = Vec::new(); while let Ok(Ok(Some(frame))) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await @@ -1916,7 +1923,7 @@ mod test { let mut import = super::Import::new(broadcast, catalog.clone()); let pmt = synth_pmt(&[(StreamType::Mpeg1Audio, MP2_PID)], false); - import.decode(&mut bytes::BytesMut::from(&pmt[..])).unwrap(); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); // Two 96-byte MP2 frames with distinct payloads; frame A is cut at byte 50. let mut frame_a = vec![0xFF, 0xFD, 0x14, 0x00]; @@ -1927,10 +1934,10 @@ mod test { let mut second = frame_a[50..].to_vec(); second.extend_from_slice(&frame_b); import - .decode(&mut audio_pes_packet(MP2_PID, 0, 90_000, &frame_a[..50]).as_slice()) + .decode(audio_pes_packet(MP2_PID, 0, 90_000, &frame_a[..50]).as_slice()) .unwrap(); import - .decode(&mut audio_pes_packet(MP2_PID, 1, 270_000, &second).as_slice()) + .decode(audio_pes_packet(MP2_PID, 1, 270_000, &second).as_slice()) .unwrap(); import.finish().unwrap(); @@ -1943,9 +1950,9 @@ mod test { ); assert_eq!(frames[1].payload.as_ref(), &frame_b[..], "frame B intact"); // Frame A began in PES 1 (PTS 90000 ticks = 1 s); frame B begins in PES 2 - // (270000 ticks = 3 s). - assert_eq!(frames[0].timestamp, Timestamp::from_millis(1_000).unwrap()); - assert_eq!(frames[1].timestamp, Timestamp::from_millis(3_000).unwrap()); + // (270000 ticks = 3 s). The legacy container normalizes to microseconds on the wire. + assert_eq!(frames[0].timestamp, Timestamp::from_micros(1_000_000).unwrap()); + assert_eq!(frames[1].timestamp, Timestamp::from_micros(3_000_000).unwrap()); } // A cut inside the next frame's header (fewer bytes left than a parseable @@ -1962,7 +1969,7 @@ mod test { let mut import = super::Import::new(broadcast, catalog.clone()); let pmt = synth_pmt(&[(StreamType::Mpeg1Audio, MP2_PID)], false); - import.decode(&mut bytes::BytesMut::from(&pmt[..])).unwrap(); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); let mut frame_a = vec![0xFF, 0xFD, 0x14, 0x00]; frame_a.resize(96, 0x55); @@ -1973,11 +1980,11 @@ mod test { let mut first = frame_a.clone(); first.extend_from_slice(&frame_b[..2]); import - .decode(&mut audio_pes_packet(MP2_PID, 0, 90_000, &first).as_slice()) + .decode(audio_pes_packet(MP2_PID, 0, 90_000, &first).as_slice()) .unwrap(); // PES 2: the rest of frame B, under a far-off PTS that must NOT apply to it. import - .decode(&mut audio_pes_packet(MP2_PID, 1, 900_000, &frame_b[2..]).as_slice()) + .decode(audio_pes_packet(MP2_PID, 1, 900_000, &frame_b[2..]).as_slice()) .unwrap(); import.finish().unwrap(); @@ -1998,8 +2005,9 @@ mod test { #[tokio::test(start_paused = true)] async fn scte35_cue_stamped_with_video_pts() { use crate::catalog::hang::{Catalog, Container}; + use crate::container::Consumer; + use crate::container::Timestamp; use crate::container::ts::catalog::Ext; - use crate::container::{Consumer, Timestamp}; const VIDEO_PID: u16 = 0x0050; @@ -2018,12 +2026,12 @@ mod test { )); bytes.extend_from_slice(&pes_packet(VIDEO_PID, 90_000)); // video sets the clock bytes.extend_from_slice(&packet(true, 0, 0, &CUE)); // then the SCTE-35 section - import.decode(&mut bytes).unwrap(); + import.decode(&bytes).unwrap(); let clock = import.last_pts.expect("video set the media clock"); import.finish().unwrap(); let name = catalog.snapshot().mpegts.tracks.keys().next().unwrap().clone(); - let track = consumer.subscribe_track(&moq_net::Track::new(name)).unwrap(); + let track = consumer.subscribe_track(&moq_net::Track::new(name.as_str())).unwrap(); let mut reader = Consumer::new(track, Container::Legacy).with_latency(std::time::Duration::ZERO); let frame = tokio::time::timeout(std::time::Duration::from_secs(1), reader.read()) .await @@ -2033,7 +2041,13 @@ mod test { assert_eq!(&frame.payload[..], &CUE[..], "verbatim splice_info_section"); assert_ne!(frame.timestamp, Timestamp::ZERO, "cue must not stamp zero"); - assert_eq!(frame.timestamp, clock, "cue stamped with the video media clock"); + // The legacy container normalizes the wire timestamp to microseconds, so compare the + // instant (not the raw scale) against the 90 kHz media clock the cue was stamped with. + assert_eq!( + std::time::Duration::from(frame.timestamp), + std::time::Duration::from(clock), + "cue stamped with the video media clock" + ); } // A 0x86 PID without CUEI is ambiguous (DTS audio or a non-conformant SCTE mux): @@ -2048,7 +2062,7 @@ mod test { const SECTION_PID: u16 = 0x0021; let mut broadcast = moq_net::Broadcast::new().produce(); - // scte35::Ext (not the base catalog) makes a wrong ensure_scte() observable: it + // catalog::Ext (not the base catalog) makes a wrong ensure_scte() observable: it // would create a rendition, which the base catalog silently drops. let catalog = crate::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); let mut import = super::Import::new(broadcast, catalog.clone()); @@ -2064,7 +2078,7 @@ mod test { )); bytes.extend_from_slice(&packet(true, 0, 0, &CUE)); // a private section on 0x21 bytes.extend_from_slice(&pes_packet(VIDEO_PID, 90_000)); // valid video after it - import.decode(&mut bytes).unwrap(); // must NOT abort + import.decode(&bytes).unwrap(); // must NOT abort assert!( import.last_pts.is_some(), @@ -2135,7 +2149,7 @@ mod test { bytes.extend_from_slice(&pes_packet(VIDEO_PID, 90_000)); // video sets the media clock let payload = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; bytes.extend_from_slice(&audio_pes_packet(DATA_PID, 0, 90_000, &payload)); - import.decode(&mut bytes).unwrap(); + import.decode(&bytes).unwrap(); import.finish().unwrap(); let snap = catalog.snapshot(); @@ -2148,7 +2162,7 @@ mod test { assert_eq!(verbatim.stream_id, Some(0xC0), "recorded the PES stream_id"); assert_eq!(track.pid, DATA_PID, "recorded the original PID"); - let track = consumer.subscribe_track(&moq_net::Track::new(name.clone())).unwrap(); + let track = consumer.subscribe_track(&moq_net::Track::new(name.as_str())).unwrap(); let mut reader = Consumer::new(track, Container::Legacy).with_latency(std::time::Duration::ZERO); let frame = tokio::time::timeout(std::time::Duration::from_secs(1), reader.read()) .await diff --git a/rs/moq-mux/src/container/ts/import_test.rs b/rs/moq-mux/src/container/ts/import_test.rs index 4b27bce67..66abb239a 100644 --- a/rs/moq-mux/src/container/ts/import_test.rs +++ b/rs/moq-mux/src/container/ts/import_test.rs @@ -11,8 +11,8 @@ fn import_ts(data: &[u8]) -> crate::catalog::hang::Catalog { let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - let mut buf = BytesMut::from(data); - import.decode(&mut buf).unwrap(); + let buf = BytesMut::from(data); + import.decode(&buf).unwrap(); import.finish().unwrap(); catalog.snapshot() @@ -194,7 +194,7 @@ fn resyncs_across_chunk_boundaries() { let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); for chunk in misaligned.chunks(100) { - import.decode(&mut BytesMut::from(chunk)).unwrap(); + import.decode(&BytesMut::from(chunk)).unwrap(); } import.finish().unwrap(); @@ -220,8 +220,8 @@ async fn import_export_import_roundtrip() { let consumer = broadcast.consume(); let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); - let mut buf = BytesMut::from(&data[..]); - import.decode(&mut buf).unwrap(); + let buf = BytesMut::from(&data[..]); + import.decode(&buf).unwrap(); import.finish().unwrap(); // Re-export to TS. `import` and `catalog` stay alive so the exporter can @@ -268,7 +268,7 @@ async fn survives_midstream_join() { let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); import - .decode(&mut BytesMut::from(&buf[..])) + .decode(&BytesMut::from(&buf[..])) .expect("a mid-stream join must not abort the demux"); import.finish().unwrap(); @@ -278,7 +278,7 @@ async fn survives_midstream_join() { // The track resumes at the keyframe: the leading delta was dropped, the IDR // anchors the one and only group. - let track = consumer.subscribe_track(&moq_net::Track::new(name)).unwrap(); + let track = consumer.subscribe_track(&moq_net::Track::new(name.as_str())).unwrap(); let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy); let mut frames = Vec::new(); while let Ok(Ok(Some(frame))) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await { @@ -307,7 +307,7 @@ async fn kyrion_dirtystart_extracts_real_cues() { .unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); import - .decode(&mut BytesMut::from(&data[..])) + .decode(&BytesMut::from(&data[..])) .expect("a dirty mid-stream join must not abort the demux"); import.finish().unwrap(); @@ -322,7 +322,7 @@ async fn kyrion_dirtystart_extracts_real_cues() { .find(|(_, t)| t.verbatim.as_ref().is_some_and(|v| v.stream_type == 0x86)) .map(|(name, _)| name.clone()) .expect("scte35 track"); - let track = consumer.subscribe_track(&moq_net::Track::new(name)).unwrap(); + let track = consumer.subscribe_track(&moq_net::Track::new(name.as_str())).unwrap(); let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy); let mut cues = Vec::new(); while let Ok(Ok(Some(frame))) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await { @@ -356,8 +356,8 @@ fn import_handles_unaligned_chunks() { let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); for chunk in data.chunks(100) { - let mut buf = BytesMut::from(chunk); - import.decode(&mut buf).unwrap(); + let buf = BytesMut::from(chunk); + import.decode(&buf).unwrap(); } import.finish().unwrap(); diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 2eda2ec50..17d8e95e3 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -1,8 +1,9 @@ /// Errors from moq-mux operations. /// -/// Most variants are simple delegations to underlying layers — [`moq_net::Error`] for -/// transport / pub-sub failures, [`hang::Error`] for catalog/codec parsing, and -/// [`fmp4::Error`](crate::container::fmp4::Error) for CMAF wire-format problems. +/// Most variants are delegations to underlying layers — [`moq_net::Error`] for +/// transport / pub-sub failures, [`hang::Error`] for catalog/codec parsing, the +/// per-format Errors for container shape problems, and the per-codec Errors for +/// bitstream parsing problems. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { @@ -22,15 +23,107 @@ pub enum Error { #[error("cmaf: {0}")] Cmaf(#[from] crate::container::fmp4::Error), + /// Error parsing or building MKV / WebM streams. + #[error("mkv: {0}")] + Mkv(#[from] crate::container::mkv::Error), + + /// Error decoding the MSF catalog. + #[error("msf: {0}")] + Msf(#[from] crate::catalog::msf::Error), + /// Error parsing or building LOC frames. #[error("loc: {0}")] Loc(#[from] moq_loc::Error), + /// Error parsing an Annex B NAL stream. + #[error("annexb: {0}")] + Annexb(#[from] crate::codec::annexb::Error), + + /// Error parsing AAC. + #[error("aac: {0}")] + Aac(#[from] crate::codec::aac::Error), + + /// Error parsing Opus. + #[error("opus: {0}")] + Opus(#[from] crate::codec::opus::Error), + + /// Error parsing H.264. + #[error("h264: {0}")] + H264(#[from] crate::codec::h264::Error), + + /// Error parsing H.265. + #[error("h265: {0}")] + H265(#[from] crate::codec::h265::Error), + + /// Error parsing AV1. + #[error("av1: {0}")] + Av1(#[from] crate::codec::av1::Error), + + /// Error parsing VP8. + #[error("vp8: {0}")] + Vp8(#[from] crate::codec::vp8::Error), + + /// Error parsing VP9. + #[error("vp9: {0}")] + Vp9(#[from] crate::codec::vp9::Error), + + /// Error parsing legacy audio (MP2 / AC-3 / E-AC-3). + #[error("legacy: {0}")] + Legacy(#[from] crate::codec::legacy::Error), + + /// Timestamp overflow when converting between timescales. + #[error("timestamp overflow")] + TimestampOverflow(#[from] moq_net::TimeOverflow), + + /// Error decoding or encoding an mp4 atom. + #[error("mp4: {0}")] + Mp4(std::sync::Arc), + + /// I/O error. + #[error("io: {0}")] + Io(std::sync::Arc), + + /// URL parse error. + #[error("url: {0}")] + Url(#[from] url::ParseError), + + /// Unknown media format. + #[error("unknown format: {0}")] + UnknownFormat(String), + + /// A non-keyframe frame was received before any keyframe opened a group. + /// A track joining mid-stream should skip frames until the first keyframe. + #[error("{0}")] + MissingKeyframe(#[from] crate::container::MissingKeyframe), + + /// Error from a muxer/demuxer that reports via `anyhow` (currently MPEG-TS). + /// Boxed in an `Arc` so the enum stays `Clone` (`anyhow::Error` is not). + #[error("{0}")] + Other(std::sync::Arc), + /// Tried to set an application catalog section whose name collides with a /// reserved media section (`video`/`audio`). #[error("reserved catalog section: {0}")] ReservedSection(String), } +impl From for Error { + fn from(err: anyhow::Error) -> Self { + Error::Other(std::sync::Arc::new(err)) + } +} + +impl From for Error { + fn from(err: mp4_atom::Error) -> Self { + Error::Mp4(std::sync::Arc::new(err)) + } +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Error::Io(std::sync::Arc::new(err)) + } +} + /// A Result type alias for moq-mux operations. pub type Result = std::result::Result; diff --git a/rs/moq-mux/src/import.rs b/rs/moq-mux/src/import.rs deleted file mode 100644 index e6b05e3a3..000000000 --- a/rs/moq-mux/src/import.rs +++ /dev/null @@ -1,672 +0,0 @@ -//! Format dispatchers for callers who only have a format string. -//! -//! [`Framed`] is the entry point when the caller already has whole -//! frames (the typical case for files and reassembled network input). -//! [`Stream`] is for raw byte streams where frame boundaries have to -//! be inferred (piped Annex-B H.264, an fMP4 reader, …). Both pick a -//! concrete importer from a [`FramedFormat`] / [`StreamFormat`] string. -//! The concrete importers themselves live with their format under -//! [`crate::container`] or [`crate::codec`]. - -use std::{fmt, str::FromStr}; - -use anyhow::Context; -use bytes::Buf; -use hang::Error; - -use crate::catalog::hang::Extra; - -/// The supported framed formats (known frame boundaries). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum FramedFormat { - /// H264 with AVCC framing (length-prefixed NALUs, out-of-band SPS/PPS). - Avc1, - /// H264 with Annex B framing (start code prefixed, inline SPS/PPS). - Avc3, - /// fMP4/CMAF container. - Fmp4, - /// aka H265 with inline SPS/PPS - Hev1, - /// AV1 with inline sequence headers - Av01, - /// Raw AAC frames (not ADTS). - Aac, - /// Raw Opus frames (not Ogg). - Opus, - /// Matroska / WebM container. - Mkv, - /// MPEG-TS (transport stream) container. - Ts, - // New variants go at the end: this enum has no repr, so inserting in the - // middle would shift the implicit discriminants of everything after it. - /// VP8 (one frame per buffer; not self-delimiting). - Vp8, - /// VP9 (one frame per buffer; not self-delimiting). - Vp9, - /// FLV (Flash Video / RTMP) container. - Flv, -} - -impl FromStr for FramedFormat { - type Err = Error; - - fn from_str(s: &str) -> Result { - match s { - "avc1" | "avcc" => Ok(FramedFormat::Avc1), - "avc3" | "h264" => Ok(FramedFormat::Avc3), - "hev1" => Ok(FramedFormat::Hev1), - "fmp4" | "cmaf" => Ok(FramedFormat::Fmp4), - "av01" | "av1" | "av1c" | "av1C" => Ok(FramedFormat::Av01), - "aac" => Ok(FramedFormat::Aac), - "opus" => Ok(FramedFormat::Opus), - "mkv" | "webm" | "matroska" => Ok(FramedFormat::Mkv), - "ts" | "mpegts" | "mpeg2ts" | "m2ts" => Ok(FramedFormat::Ts), - "vp8" | "vp08" => Ok(FramedFormat::Vp8), - "vp9" | "vp09" => Ok(FramedFormat::Vp9), - "flv" => Ok(FramedFormat::Flv), - _ => Err(Error::UnknownFormat(s.to_string())), - } - } -} - -impl fmt::Display for FramedFormat { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - FramedFormat::Avc1 => write!(f, "avc1"), - FramedFormat::Avc3 => write!(f, "avc3"), - FramedFormat::Fmp4 => write!(f, "fmp4"), - FramedFormat::Hev1 => write!(f, "hev1"), - FramedFormat::Av01 => write!(f, "av01"), - FramedFormat::Aac => write!(f, "aac"), - FramedFormat::Opus => write!(f, "opus"), - FramedFormat::Mkv => write!(f, "mkv"), - FramedFormat::Ts => write!(f, "ts"), - FramedFormat::Vp8 => write!(f, "vp8"), - FramedFormat::Vp9 => write!(f, "vp9"), - FramedFormat::Flv => write!(f, "flv"), - } - } -} - -impl From for FramedFormat { - fn from(format: StreamFormat) -> Self { - match format { - StreamFormat::Avc3 => FramedFormat::Avc3, - StreamFormat::Fmp4 => FramedFormat::Fmp4, - StreamFormat::Hev1 => FramedFormat::Hev1, - StreamFormat::Av01 => FramedFormat::Av01, - StreamFormat::Mkv => FramedFormat::Mkv, - StreamFormat::Ts => FramedFormat::Ts, - StreamFormat::Flv => FramedFormat::Flv, - } - } -} - -enum FramedKind { - /// H.264 (both avc1 and avc3 wire shapes go through this importer; mode - /// is pinned by the caller's FramedFormat choice). - H264(crate::codec::h264::Import), - // Boxed because it's a large struct and clippy complains about the size. - Fmp4(Box), - Hev1(crate::codec::h265::Import), - Av01(crate::codec::av1::Import), - Vp8(crate::codec::vp8::Import), - Vp9(crate::codec::vp9::Import), - Aac(crate::codec::aac::Import), - Opus(crate::codec::opus::Import), - // Boxed for the same reason as Fmp4. - Mkv(Box), - // Boxed for the same reason as Fmp4. - Ts(Box>), - // Boxed for the same reason as Fmp4. - Flv(Box), -} - -/// An importer for formats with known frame boundaries. -/// -/// This supports all formats and should be used when the caller knows the frame boundaries. -pub struct Framed { - decoder: FramedKind, -} - -impl Framed { - /// Create a new framed importer with the given format and initialization data. - /// - /// The buffer will be fully consumed, or an error will be returned. - pub fn new>( - broadcast: moq_net::BroadcastProducer, - catalog: crate::catalog::Producer, - format: FramedFormat, - buf: &mut T, - ) -> anyhow::Result { - use crate::codec::h264::Mode as H264Mode; - let decoder = match format { - FramedFormat::Avc1 => { - let mut decoder = crate::codec::h264::Import::new(broadcast, catalog).with_mode(H264Mode::Avc1)?; - decoder.initialize(buf)?; - FramedKind::H264(decoder) - } - FramedFormat::Avc3 => { - let mut decoder = crate::codec::h264::Import::new(broadcast, catalog).with_mode(H264Mode::Avc3)?; - decoder.initialize(buf)?; - FramedKind::H264(decoder) - } - FramedFormat::Fmp4 => { - let mut decoder = Box::new(crate::container::fmp4::Import::new(broadcast, catalog)); - decoder.decode(buf)?; - FramedKind::Fmp4(decoder) - } - FramedFormat::Hev1 => { - let mut decoder = crate::codec::h265::Import::new(broadcast, catalog); - decoder.initialize(buf)?; - FramedKind::Hev1(decoder) - } - FramedFormat::Av01 => { - let mut decoder = crate::codec::av1::Import::new(broadcast, catalog); - decoder.initialize(buf)?; - FramedKind::Av01(decoder) - } - FramedFormat::Vp8 => { - let mut decoder = crate::codec::vp8::Import::new(broadcast, catalog); - decoder.initialize(buf)?; - FramedKind::Vp8(decoder) - } - FramedFormat::Vp9 => { - let mut decoder = crate::codec::vp9::Import::new(broadcast, catalog); - decoder.initialize(buf)?; - FramedKind::Vp9(decoder) - } - FramedFormat::Aac => { - let config = crate::codec::aac::Config::parse(buf)?; - FramedKind::Aac(crate::codec::aac::Import::new(broadcast, catalog, config)?) - } - FramedFormat::Opus => { - let config = crate::codec::opus::Config::parse(buf)?; - FramedKind::Opus(crate::codec::opus::Import::new(broadcast, catalog, config)?) - } - FramedFormat::Mkv => { - let mut decoder = Box::new(crate::container::mkv::Import::new(broadcast, catalog)); - decoder.decode(buf)?; - FramedKind::Mkv(decoder) - } - FramedFormat::Ts => { - let mut decoder = Box::new(crate::container::ts::Import::new(broadcast, catalog)); - decoder.decode(buf)?; - FramedKind::Ts(decoder) - } - FramedFormat::Flv => { - let mut decoder = Box::new(crate::container::flv::Import::new(broadcast, catalog)); - decoder.decode(buf)?; - FramedKind::Flv(decoder) - } - }; - - anyhow::ensure!(!buf.has_remaining(), "buffer was not fully consumed"); - - Ok(Self { decoder }) - } - - /// Create a new framed importer that publishes on an existing track. - /// - /// Only single-track formats are supported. Container formats that may - /// create multiple MoQ tracks need an explicit track mapping API. - pub fn new_with_track>( - track: moq_net::TrackProducer, - catalog: crate::catalog::Producer, - format: FramedFormat, - buf: &mut T, - ) -> anyhow::Result { - use crate::codec::h264::Mode as H264Mode; - let decoder = match format { - FramedFormat::Avc1 => { - let mut decoder = - crate::codec::h264::Import::new_with_track(track, catalog).with_mode(H264Mode::Avc1)?; - decoder.initialize(buf)?; - FramedKind::H264(decoder) - } - FramedFormat::Avc3 => { - let mut decoder = - crate::codec::h264::Import::new_with_track(track, catalog).with_mode(H264Mode::Avc3)?; - decoder.initialize(buf)?; - FramedKind::H264(decoder) - } - FramedFormat::Hev1 => { - let mut decoder = crate::codec::h265::Import::new_with_track(track, catalog); - decoder.initialize(buf)?; - FramedKind::Hev1(decoder) - } - FramedFormat::Av01 => { - let mut decoder = crate::codec::av1::Import::new_with_track(track, catalog); - decoder.initialize(buf)?; - FramedKind::Av01(decoder) - } - FramedFormat::Vp8 => { - let mut decoder = crate::codec::vp8::Import::new_with_track(track, catalog); - decoder.initialize(buf)?; - FramedKind::Vp8(decoder) - } - FramedFormat::Vp9 => { - let mut decoder = crate::codec::vp9::Import::new_with_track(track, catalog); - decoder.initialize(buf)?; - FramedKind::Vp9(decoder) - } - FramedFormat::Aac => { - let config = crate::codec::aac::Config::parse(buf)?; - FramedKind::Aac(crate::codec::aac::Import::new_with_track(track, catalog, config)?) - } - FramedFormat::Opus => { - let config = crate::codec::opus::Config::parse(buf)?; - FramedKind::Opus(crate::codec::opus::Import::new_with_track(track, catalog, config)?) - } - FramedFormat::Fmp4 | FramedFormat::Mkv | FramedFormat::Ts | FramedFormat::Flv => { - anyhow::bail!("{format} can publish multiple tracks") - } - }; - - anyhow::ensure!(!buf.has_remaining(), "buffer was not fully consumed"); - - Ok(Self { decoder }) - } - - /// Finish the decoder, flushing any buffered data. - pub fn finish(&mut self) -> anyhow::Result<()> { - match self.decoder { - FramedKind::H264(ref mut decoder) => decoder.finish(), - FramedKind::Fmp4(ref mut decoder) => decoder.finish(), - FramedKind::Hev1(ref mut decoder) => decoder.finish(), - FramedKind::Av01(ref mut decoder) => decoder.finish(), - FramedKind::Vp8(ref mut decoder) => decoder.finish(), - FramedKind::Vp9(ref mut decoder) => decoder.finish(), - FramedKind::Aac(ref mut decoder) => decoder.finish(), - FramedKind::Opus(ref mut decoder) => decoder.finish(), - FramedKind::Mkv(ref mut decoder) => decoder.finish(), - FramedKind::Ts(ref mut decoder) => decoder.finish(), - FramedKind::Flv(ref mut decoder) => decoder.finish(), - } - } - - /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - match self.decoder { - FramedKind::H264(ref mut decoder) => decoder.seek(sequence), - FramedKind::Fmp4(ref mut decoder) => decoder.seek(sequence), - FramedKind::Hev1(ref mut decoder) => decoder.seek(sequence), - FramedKind::Av01(ref mut decoder) => decoder.seek(sequence), - FramedKind::Vp8(ref mut decoder) => decoder.seek(sequence), - FramedKind::Vp9(ref mut decoder) => decoder.seek(sequence), - FramedKind::Aac(ref mut decoder) => decoder.seek(sequence), - FramedKind::Opus(ref mut decoder) => decoder.seek(sequence), - FramedKind::Mkv(ref mut decoder) => decoder.seek(sequence), - FramedKind::Ts(ref mut decoder) => decoder.seek(sequence), - FramedKind::Flv(ref mut decoder) => decoder.seek(sequence), - } - } - - /// Return the single track produced by this importer. - pub fn track(&self) -> anyhow::Result<&moq_net::TrackProducer> { - match self.decoder { - FramedKind::H264(ref decoder) => decoder.track().context("H.264 track not yet created"), - FramedKind::Fmp4(_) => anyhow::bail!("fmp4 can contain multiple tracks"), - FramedKind::Hev1(ref decoder) => decoder.track(), - FramedKind::Av01(ref decoder) => decoder.track(), - FramedKind::Vp8(ref decoder) => decoder.track(), - FramedKind::Vp9(ref decoder) => decoder.track(), - FramedKind::Aac(ref decoder) => Ok(decoder.track()), - FramedKind::Opus(ref decoder) => Ok(decoder.track()), - FramedKind::Mkv(_) => anyhow::bail!("mkv can contain multiple tracks"), - FramedKind::Ts(_) => anyhow::bail!("ts can contain multiple tracks"), - FramedKind::Flv(_) => anyhow::bail!("flv can contain multiple tracks"), - } - } - - /// Decode a frame from the given buffer. - pub fn decode_frame>( - &mut self, - buf: &mut T, - pts: Option, - ) -> anyhow::Result<()> { - match self.decoder { - FramedKind::H264(ref mut decoder) => decoder.decode_frame(buf, pts)?, - FramedKind::Fmp4(ref mut decoder) => decoder.decode(buf)?, - FramedKind::Hev1(ref mut decoder) => decoder.decode_frame(buf, pts)?, - FramedKind::Av01(ref mut decoder) => decoder.decode_frame(buf, pts)?, - FramedKind::Vp8(ref mut decoder) => decoder.decode_frame(buf, pts)?, - FramedKind::Vp9(ref mut decoder) => decoder.decode_frame(buf, pts)?, - FramedKind::Aac(ref mut decoder) => decoder.decode(buf, pts)?, - FramedKind::Opus(ref mut decoder) => decoder.decode(buf, pts)?, - FramedKind::Mkv(ref mut decoder) => { - let _ = pts; - decoder.decode(buf)?; - } - FramedKind::Ts(ref mut decoder) => { - let _ = pts; - decoder.decode(buf)?; - } - FramedKind::Flv(ref mut decoder) => { - let _ = pts; - decoder.decode(buf)?; - } - } - - anyhow::ensure!(!buf.has_remaining(), "buffer was not fully consumed"); - - Ok(()) - } -} - -// Lift an already-built codec importer into a `Framed` so callers that build -// their config out-of-band (e.g. moq-gst, which constructs `opus::Config` from -// gstreamer caps instead of an OpusHead buffer) can keep using `.into()`. -impl From for Framed { - fn from(opus: crate::codec::opus::Import) -> Self { - Self { - decoder: FramedKind::Opus(opus), - } - } -} - -impl From> for Framed { - fn from(aac: crate::codec::aac::Import) -> Self { - Self { - decoder: FramedKind::Aac(aac), - } - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use bytes::Bytes; - - use super::*; - use crate::container::Timestamp; - - fn opus_head() -> Vec { - let mut head = Vec::with_capacity(19); - head.extend_from_slice(b"OpusHead"); - head.push(1); - head.push(2); - head.extend_from_slice(&0u16.to_le_bytes()); - head.extend_from_slice(&48000u32.to_le_bytes()); - head.extend_from_slice(&0u16.to_le_bytes()); - head.push(0); - head - } - - fn h264_init() -> Vec { - let mut init = Vec::new(); - init.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); - init.extend_from_slice(&[ - 0x67, 0x64, 0x00, 0x1f, 0xac, 0x24, 0x84, 0x01, 0x40, 0x16, 0xec, 0x04, 0x40, 0x00, 0x00, 0x03, 0x00, 0x40, - 0x00, 0x00, 0x0c, 0x23, 0xc6, 0x0c, 0x92, - ]); - init.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); - init.extend_from_slice(&[0x68, 0xee, 0x32, 0xc8, 0xb0]); - init - } - - fn new_broadcast() -> (moq_net::BroadcastProducer, crate::catalog::Producer) { - let mut broadcast = moq_net::Broadcast::new().produce(); - let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); - (broadcast, catalog) - } - - #[tokio::test(start_paused = true)] - async fn fixed_track_opus_uses_existing_name_and_delivers_frames() { - let (mut broadcast, catalog) = new_broadcast(); - let track = broadcast.create_track(moq_net::Track::new("requested-audio")).unwrap(); - let consumer = track.consume(); - let init = opus_head(); - let mut init = init.as_slice(); - - let mut framed = Framed::new_with_track(track, catalog.clone(), FramedFormat::Opus, &mut init).unwrap(); - - assert_eq!(framed.track().unwrap().name, "requested-audio"); - let snapshot = catalog.snapshot(); - assert!(snapshot.audio.renditions.contains_key("requested-audio")); - assert!(!snapshot.audio.renditions.contains_key("0.opus")); - - let mut media = crate::container::Consumer::new(consumer, crate::catalog::hang::Container::Legacy); - let payload = b"opus payload".to_vec(); - let mut frame = payload.as_slice(); - framed - .decode_frame(&mut frame, Some(Timestamp::from_micros(1_000).unwrap())) - .unwrap(); - - let frame = tokio::time::timeout(Duration::from_secs(1), media.read()) - .await - .unwrap() - .unwrap() - .unwrap(); - assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp, Timestamp::from_micros(1_000).unwrap()); - - framed.finish().unwrap(); - } - - #[tokio::test(start_paused = true)] - async fn fixed_track_h264_uses_existing_name_in_catalog() { - let (mut broadcast, catalog) = new_broadcast(); - let track = broadcast.create_track(moq_net::Track::new("camera")).unwrap(); - let init = h264_init(); - let mut init = init.as_slice(); - - let framed = Framed::new_with_track(track, catalog.clone(), FramedFormat::Avc3, &mut init).unwrap(); - - assert_eq!(framed.track().unwrap().name, "camera"); - let snapshot = catalog.snapshot(); - let video = snapshot.video.renditions.get("camera").unwrap(); - assert_eq!(video.coded_width, Some(1280)); - assert_eq!(video.coded_height, Some(720)); - assert!(!snapshot.video.renditions.contains_key("0.avc3")); - } - - #[test] - fn fixed_track_rejects_multi_track_formats() { - for format in [FramedFormat::Fmp4, FramedFormat::Mkv, FramedFormat::Ts] { - let (mut broadcast, catalog) = new_broadcast(); - let track = broadcast.create_track(moq_net::Track::new("media")).unwrap(); - let mut init = Bytes::new(); - - let err = match Framed::new_with_track(track, catalog, format, &mut init) { - Ok(_) => panic!("multi-track format should be rejected"), - Err(err) => err, - }; - assert!(err.to_string().contains("multiple tracks")); - } - } - - #[tokio::test(start_paused = true)] - async fn fixed_track_reconfiguration_errors() { - let (mut broadcast, catalog) = new_broadcast(); - let track = broadcast.create_track(moq_net::Track::new("video")).unwrap(); - let mut init = Bytes::new(); - let mut framed = Framed::new_with_track(track, catalog, FramedFormat::Vp8, &mut init).unwrap(); - - let mut first = Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]); - framed - .decode_frame(&mut first, Some(Timestamp::from_micros(0).unwrap())) - .unwrap(); - - let mut second = Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01]); - let err = framed - .decode_frame(&mut second, Some(Timestamp::from_micros(33_000).unwrap())) - .unwrap_err(); - assert!(err.to_string().contains("fixed track cannot be reconfigured")); - } -} - -// -- stream dispatcher -- - -/// Formats that support stream decoding (unknown frame boundaries). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum StreamFormat { - /// aka H264 with inline SPS/PPS - Avc3, - /// fMP4/CMAF container. - Fmp4, - /// aka H265 with inline SPS/PPS - Hev1, - /// AV1 with inline sequence headers - Av01, - /// Matroska / WebM container. - Mkv, - /// MPEG-TS (transport stream) container. - Ts, - /// FLV (Flash Video / RTMP) container. - Flv, -} - -impl FromStr for StreamFormat { - type Err = Error; - - fn from_str(s: &str) -> Result { - match s { - "avc3" | "h264" => Ok(StreamFormat::Avc3), - "hev1" => Ok(StreamFormat::Hev1), - "fmp4" | "cmaf" => Ok(StreamFormat::Fmp4), - "av01" | "av1" | "av1c" | "av1C" => Ok(StreamFormat::Av01), - "mkv" | "webm" | "matroska" => Ok(StreamFormat::Mkv), - "ts" | "mpegts" | "mpeg2ts" | "m2ts" => Ok(StreamFormat::Ts), - "flv" => Ok(StreamFormat::Flv), - _ => Err(Error::UnknownFormat(s.to_string())), - } - } -} - -impl fmt::Display for StreamFormat { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - StreamFormat::Avc3 => write!(f, "avc3"), - StreamFormat::Fmp4 => write!(f, "fmp4"), - StreamFormat::Hev1 => write!(f, "hev1"), - StreamFormat::Av01 => write!(f, "av01"), - StreamFormat::Mkv => write!(f, "mkv"), - StreamFormat::Ts => write!(f, "ts"), - StreamFormat::Flv => write!(f, "flv"), - } - } -} - -enum StreamKind { - /// H.264 in avc3 wire shape (Annex-B with inline SPS/PPS). - Avc3(crate::codec::h264::Import), - // Boxed because it's a large struct and clippy complains about the size. - Fmp4(Box), - Hev1(crate::codec::h265::Import), - Av01(crate::codec::av1::Import), - // Boxed for the same reason as Fmp4. - Mkv(Box), - // Boxed for the same reason as Fmp4. - Ts(Box>), - // Boxed for the same reason as Fmp4. - Flv(Box), -} - -/// An importer for formats that support stream decoding (unknown frame boundaries). -/// -/// This includes formats like H.264 (AVC3), H.265 (HEV1), and fMP4/CMAF. -/// Use this when the caller does not know the frame boundaries. -pub struct Stream { - decoder: StreamKind, -} - -impl Stream { - /// Create a new stream importer with the given format. - pub fn new( - broadcast: moq_net::BroadcastProducer, - catalog: crate::catalog::Producer, - format: StreamFormat, - ) -> anyhow::Result { - use crate::codec::h264::Mode as H264Mode; - let decoder = match format { - StreamFormat::Avc3 => { - StreamKind::Avc3(crate::codec::h264::Import::new(broadcast, catalog).with_mode(H264Mode::Avc3)?) - } - StreamFormat::Fmp4 => StreamKind::Fmp4(Box::new(crate::container::fmp4::Import::new(broadcast, catalog))), - StreamFormat::Hev1 => StreamKind::Hev1(crate::codec::h265::Import::new(broadcast, catalog)), - StreamFormat::Av01 => StreamKind::Av01(crate::codec::av1::Import::new(broadcast, catalog)), - StreamFormat::Mkv => StreamKind::Mkv(Box::new(crate::container::mkv::Import::new(broadcast, catalog))), - StreamFormat::Ts => StreamKind::Ts(Box::new(crate::container::ts::Import::new(broadcast, catalog))), - StreamFormat::Flv => StreamKind::Flv(Box::new(crate::container::flv::Import::new(broadcast, catalog))), - }; - - Ok(Self { decoder }) - } - - /// Initialize the decoder with the given buffer and populate the broadcast. - /// - /// This is not required for self-describing formats like fMP4 or AVC3. - /// - /// The buffer will be fully consumed, or an error will be returned. - pub fn initialize>(&mut self, buf: &mut T) -> anyhow::Result<()> { - match self.decoder { - StreamKind::Avc3(ref mut decoder) => decoder.initialize(buf)?, - StreamKind::Fmp4(ref mut decoder) => decoder.decode(buf)?, - StreamKind::Hev1(ref mut decoder) => decoder.initialize(buf)?, - StreamKind::Av01(ref mut decoder) => decoder.initialize(buf)?, - StreamKind::Mkv(ref mut decoder) => decoder.decode(buf)?, - StreamKind::Ts(ref mut decoder) => decoder.decode(buf)?, - StreamKind::Flv(ref mut decoder) => decoder.decode(buf)?, - } - - anyhow::ensure!(!buf.has_remaining(), "buffer was not fully consumed"); - - Ok(()) - } - - /// Decode a stream of data from the given buffer. - pub fn decode_stream>(&mut self, buf: &mut T) -> anyhow::Result<()> { - match self.decoder { - StreamKind::Avc3(ref mut decoder) => decoder.decode_stream(buf, None), - StreamKind::Fmp4(ref mut decoder) => decoder.decode(buf), - StreamKind::Hev1(ref mut decoder) => decoder.decode_stream(buf, None), - StreamKind::Av01(ref mut decoder) => decoder.decode_stream(buf, None), - StreamKind::Mkv(ref mut decoder) => decoder.decode(buf), - StreamKind::Ts(ref mut decoder) => decoder.decode(buf), - StreamKind::Flv(ref mut decoder) => decoder.decode(buf), - } - } - - /// Finish the decoder, flushing any buffered data. - pub fn finish(&mut self) -> anyhow::Result<()> { - match self.decoder { - StreamKind::Avc3(ref mut decoder) => decoder.finish(), - StreamKind::Fmp4(ref mut decoder) => decoder.finish(), - StreamKind::Hev1(ref mut decoder) => decoder.finish(), - StreamKind::Av01(ref mut decoder) => decoder.finish(), - StreamKind::Mkv(ref mut decoder) => decoder.finish(), - StreamKind::Ts(ref mut decoder) => decoder.finish(), - StreamKind::Flv(ref mut decoder) => decoder.finish(), - } - } - - /// Close the current group and open the next one at `sequence`. - pub fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - match self.decoder { - StreamKind::Avc3(ref mut decoder) => decoder.seek(sequence), - StreamKind::Fmp4(ref mut decoder) => decoder.seek(sequence), - StreamKind::Hev1(ref mut decoder) => decoder.seek(sequence), - StreamKind::Av01(ref mut decoder) => decoder.seek(sequence), - StreamKind::Mkv(ref mut decoder) => decoder.seek(sequence), - StreamKind::Ts(ref mut decoder) => decoder.seek(sequence), - StreamKind::Flv(ref mut decoder) => decoder.seek(sequence), - } - } - - /// Check if the decoder has read enough data to be initialized. - pub fn is_initialized(&self) -> bool { - match self.decoder { - StreamKind::Avc3(ref decoder) => decoder.is_initialized(), - StreamKind::Fmp4(ref decoder) => decoder.is_initialized(), - StreamKind::Hev1(ref decoder) => decoder.is_initialized(), - StreamKind::Av01(ref decoder) => decoder.is_initialized(), - StreamKind::Mkv(ref decoder) => decoder.is_initialized(), - StreamKind::Ts(ref decoder) => decoder.is_initialized(), - StreamKind::Flv(ref decoder) => decoder.is_initialized(), - } - } -} diff --git a/rs/moq-mux/src/import/container.rs b/rs/moq-mux/src/import/container.rs new file mode 100644 index 000000000..b40892d9f --- /dev/null +++ b/rs/moq-mux/src/import/container.rs @@ -0,0 +1,152 @@ +//! Container importers. +//! +//! [`Container`] decodes a container from whole chunks; [`ContainerStream`] +//! decodes it from a raw byte stream. A container may publish more than one MoQ +//! track, so neither exposes a single-track demand/name handle. Today every +//! container supports both; both wrap the same [`ContainerImpl`] dispatch. + +use crate::Result; + +/// The concrete container importers, shared by [`Container`] and +/// [`ContainerStream`]. Containers parse their own internal framing, so a whole +/// chunk and a stream chunk decode identically. +enum ContainerImpl { + // Boxed because it's a large struct and clippy complains about the size. + Fmp4(Box>), + Mkv(Box>), + Ts(Box>), + Flv(Box>), +} + +impl ContainerImpl { + fn fmp4(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { + ContainerImpl::Fmp4(Box::new(crate::container::fmp4::Import::new(broadcast, catalog))) + } + + fn mkv(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { + ContainerImpl::Mkv(Box::new(crate::container::mkv::Import::new(broadcast, catalog))) + } + + fn ts(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { + ContainerImpl::Ts(Box::new(crate::container::ts::Import::new(broadcast, catalog))) + } + + fn flv(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { + ContainerImpl::Flv(Box::new(crate::container::flv::Import::new(broadcast, catalog))) + } + + fn decode(&mut self, data: &[u8]) -> Result<()> { + match self { + ContainerImpl::Fmp4(decoder) => decoder.decode(data), + ContainerImpl::Mkv(decoder) => decoder.decode(data), + ContainerImpl::Ts(decoder) => decoder.decode(data).map_err(Into::into), + ContainerImpl::Flv(decoder) => decoder.decode(data).map_err(Into::into), + } + } + + fn finish(&mut self) -> Result<()> { + match self { + ContainerImpl::Fmp4(decoder) => decoder.finish(), + ContainerImpl::Mkv(decoder) => decoder.finish(), + ContainerImpl::Ts(decoder) => decoder.finish().map_err(Into::into), + ContainerImpl::Flv(decoder) => decoder.finish().map_err(Into::into), + } + } + + fn seek(&mut self, sequence: u64) -> Result<()> { + match self { + ContainerImpl::Fmp4(decoder) => decoder.seek(sequence), + ContainerImpl::Mkv(decoder) => decoder.seek(sequence), + ContainerImpl::Ts(decoder) => decoder.seek(sequence).map_err(Into::into), + ContainerImpl::Flv(decoder) => decoder.seek(sequence).map_err(Into::into), + } + } +} + +/// A container importer for whole chunks. +/// +/// Use this when the caller hands over discrete buffers (the typical case for +/// files and reassembled network input). May publish more than one track. +pub struct Container { + inner: ContainerImpl, +} + +impl Container { + /// Create a new container importer, decoding the initial chunk. + pub fn new( + broadcast: moq_net::BroadcastProducer, + catalog: crate::catalog::Producer, + format: &str, + init: &[u8], + ) -> Result { + let mut inner = match format { + "fmp4" | "cmaf" => ContainerImpl::fmp4(broadcast, catalog), + "mkv" | "webm" | "matroska" => ContainerImpl::mkv(broadcast, catalog), + "ts" | "mpegts" | "mpeg2ts" | "m2ts" => ContainerImpl::ts(broadcast, catalog), + "flv" => ContainerImpl::flv(broadcast, catalog), + _ => return Err(crate::Error::UnknownFormat(format.to_string())), + }; + inner.decode(init)?; + Ok(Self { inner }) + } + + /// Decode a chunk of container bytes. + pub fn decode(&mut self, data: &[u8]) -> Result<()> { + self.inner.decode(data) + } + + /// Finish the importer, flushing any buffered data. + pub fn finish(&mut self) -> Result<()> { + self.inner.finish() + } + + /// Close the current group and open the next one at `sequence`. + pub fn seek(&mut self, sequence: u64) -> Result<()> { + self.inner.seek(sequence) + } +} + +/// A container importer for a raw byte stream. +/// +/// Use this when the caller pushes arbitrary byte chunks and the container +/// recovers its own framing. May publish more than one track. +pub struct ContainerStream { + inner: ContainerImpl, +} + +impl ContainerStream { + /// Create a new container stream importer. + pub fn new( + broadcast: moq_net::BroadcastProducer, + catalog: crate::catalog::Producer, + format: &str, + ) -> Result { + // A separate list from [`Container::new`]: only containers that can be + // recovered from a raw byte stream belong here. Today that's all of them, + // but a non-streamable container (e.g. RTP) would be added to `Container` + // alone. + let inner = match format { + "fmp4" | "cmaf" => ContainerImpl::fmp4(broadcast, catalog), + "mkv" | "webm" | "matroska" => ContainerImpl::mkv(broadcast, catalog), + "ts" | "mpegts" | "mpeg2ts" | "m2ts" => ContainerImpl::ts(broadcast, catalog), + "flv" => ContainerImpl::flv(broadcast, catalog), + _ => return Err(crate::Error::UnknownFormat(format.to_string())), + }; + Ok(Self { inner }) + } + + /// Decode a chunk of the byte stream. + pub fn decode(&mut self, data: &[u8]) -> Result<()> { + self.inner.decode(data) + } + + /// Finish the importer, flushing any buffered data. + pub fn finish(&mut self) -> Result<()> { + self.inner.finish() + } + + /// Close the current group and open the next one at `sequence`. + pub fn seek(&mut self, sequence: u64) -> Result<()> { + self.inner.seek(sequence) + } +} diff --git a/rs/moq-mux/src/import/mod.rs b/rs/moq-mux/src/import/mod.rs new file mode 100644 index 000000000..7a66277cd --- /dev/null +++ b/rs/moq-mux/src/import/mod.rs @@ -0,0 +1,33 @@ +//! Import media into a moq broadcast. +//! +//! The importers split along two axes. By multiplicity: [`Track`] / [`TrackStream`] +//! publish a single codec onto one MoQ track, while [`Container`] / +//! [`ContainerStream`] decode a container that may publish more than one track. By +//! frame boundaries: [`Track`] / [`Container`] take whole frames or chunks (the +//! typical case for files and reassembled network input), while [`TrackStream`] / +//! [`ContainerStream`] infer boundaries from a raw byte stream (piped Annex-B +//! H.264, an fMP4 reader, …). +//! +//! Each importer's `new` takes a format string (e.g. `"avc3"`, `"fmp4"`) and +//! errors on a format it doesn't handle — `TrackStream` / `ContainerStream` +//! accept only the self-delimiting formats. The concrete importers live with +//! their format under [`crate::container`] or [`crate::codec`] and publish their +//! own catalog rendition (see [`crate::catalog::VideoTrack`] / +//! [`crate::catalog::AudioTrack`]). +//! +//! [`unique_track`] mints a track for the single-codec importers. + +mod container; +mod track; + +pub use container::*; +pub use track::*; + +/// Mint a fresh unique track for a legacy single-codec importer. +/// +/// Picks a unique name from `suffix`. The legacy importers stamp their frames at the +/// microsecond timescale (see [`container::Timestamp`](crate::container::Timestamp)). Hand the +/// result to the importer's `new`. +pub fn unique_track(broadcast: &mut moq_net::BroadcastProducer, suffix: &str) -> crate::Result { + Ok(broadcast.unique_track(suffix)?) +} diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs new file mode 100644 index 000000000..a770c59ab --- /dev/null +++ b/rs/moq-mux/src/import/track.rs @@ -0,0 +1,642 @@ +//! Single-codec importers. +//! +//! [`Track`] publishes one MoQ track from whole frames; [`TrackStream`] does the +//! same from a raw byte stream where frame boundaries have to be inferred. Both +//! own exactly one track, so they expose [`Track::demand`] / [`Track::name`] +//! directly rather than fallibly. + +use crate::Result; +use crate::catalog::hang::CatalogExt; + +/// Build an H.264 avc3 split + import pair, resolving the config from `init`. +/// +/// The import reads `init` for the codec config; the split then reads it as the +/// leading bytes of the stream (caching any inline SPS/PPS). Any frames in the +/// init buffer are published. +fn build_h264_avc3( + track: moq_net::TrackProducer, + catalog: crate::catalog::Producer, + init: &[u8], +) -> Result<(crate::codec::h264::Split, crate::codec::h264::Import)> { + let mut import = crate::codec::h264::Import::new(track, catalog); + import.initialize(init)?; + let mut split = crate::codec::h264::Split::new(); + let frames = split.decode(init, None)?; + import.decode(frames)?; + Ok((split, import)) +} + +/// Build an H.264 avc1 import, resolving the config and the NALU length size from +/// the avcC. avc1 has no splitter: each access unit is wrapped directly via +/// [`crate::codec::h264::avc1_frame`]. +fn build_h264_avc1( + track: moq_net::TrackProducer, + catalog: crate::catalog::Producer, + init: &[u8], +) -> Result<(usize, crate::codec::h264::Import)> { + let mut import = crate::codec::h264::Import::new(track, catalog); + import.initialize(init)?; + let length_size = crate::codec::h264::Avcc::parse(init)?.length_size; + Ok((length_size, import)) +} + +/// Build an H.265 split + import pair, resolving the config from `init`. +fn build_h265( + track: moq_net::TrackProducer, + catalog: crate::catalog::Producer, + init: &[u8], +) -> Result<(crate::codec::h265::Split, crate::codec::h265::Import)> { + let mut import = crate::codec::h265::Import::new(track, catalog); + import.initialize(init)?; + let mut split = crate::codec::h265::Split::new(); + let frames = split.decode(init, None)?; + import.decode(frames)?; + Ok((split, import)) +} + +/// Build an AV1 split + import pair, resolving the config from `init`. +fn build_av1( + track: moq_net::TrackProducer, + catalog: crate::catalog::Producer, + init: &[u8], +) -> Result<(crate::codec::av1::Split, crate::codec::av1::Import)> { + let mut import = crate::codec::av1::Import::new(track, catalog); + import.initialize(init)?; + let mut split = crate::codec::av1::Split::new(); + // av1C (leading 0x81, ISO/IEC 14496-15) is an out-of-band config record, not an + // OBU stream, so it's read for config (above) and dropped here. Raw OBUs are the + // leading bytes of the stream and feed the splitter. + let frames = if init.len() >= 16 && init[0] == 0x81 { + Vec::new() + } else { + split.decode(init, None)? + }; + import.decode(frames)?; + Ok((split, import)) +} + +enum TrackKind { + /// H.264 avc3 (Annex-B, inline SPS/PPS). The split owns byte parsing; the + /// import publishes. + Avc3 { + split: crate::codec::h264::Split, + import: crate::codec::h264::Import, + }, + /// H.264 avc1 (length-prefixed NALU, out-of-band avcC). No splitter: each + /// access unit is wrapped directly. `length_size` is the NALU length prefix + /// width read from the avcC. + Avc1 { + length_size: usize, + import: crate::codec::h264::Import, + }, + Hev1 { + split: crate::codec::h265::Split, + import: crate::codec::h265::Import, + }, + Av01 { + split: crate::codec::av1::Split, + import: crate::codec::av1::Import, + }, + Vp8(crate::codec::vp8::Import), + Vp9(crate::codec::vp9::Import), + Aac(crate::codec::aac::Import), + Opus(crate::codec::opus::Import), +} + +/// A single-codec importer for whole frames. +/// +/// Use this when the caller already has whole frames (the typical case for files +/// and reassembled network input). Each [`decode`](Self::decode) call takes one +/// complete frame. +pub struct Track { + kind: TrackKind, +} + +impl Track { + /// Create an importer that publishes a single codec onto an existing track. + /// + /// The caller mints the track (by name) with + /// [`BroadcastProducer::create_track`](moq_net::BroadcastProducer::create_track) (or + /// [`unique_track`](crate::import::unique_track)) and hands it here. + /// The catalog rendition is registered once the codec config is resolved. + pub fn new( + track: moq_net::TrackProducer, + catalog: crate::catalog::Producer, + format: &str, + init: &[u8], + ) -> Result { + let kind = match format { + "avc1" | "avcc" => { + let (length_size, import) = build_h264_avc1(track, catalog, init)?; + TrackKind::Avc1 { length_size, import } + } + "avc3" | "h264" => { + let (split, import) = build_h264_avc3(track, catalog, init)?; + TrackKind::Avc3 { split, import } + } + "hev1" => { + let (split, import) = build_h265(track, catalog, init)?; + TrackKind::Hev1 { split, import } + } + "av01" | "av1" | "av1c" | "av1C" => { + let (split, import) = build_av1(track, catalog, init)?; + TrackKind::Av01 { split, import } + } + "vp8" | "vp08" => { + let mut import = crate::codec::vp8::Import::new(track, catalog); + import.initialize(init)?; + TrackKind::Vp8(import) + } + "vp9" | "vp09" => { + let mut import = crate::codec::vp9::Import::new(track, catalog); + import.initialize(init)?; + TrackKind::Vp9(import) + } + "aac" => { + let mut data = init; + let config = crate::codec::aac::Config::parse(&mut data)?; + let import = crate::codec::aac::Import::new(track, catalog, config)?; + TrackKind::Aac(import) + } + "opus" => { + let mut data = init; + let config = crate::codec::opus::Config::parse(&mut data)?; + let import = crate::codec::opus::Import::new(track, catalog, config)?; + TrackKind::Opus(import) + } + _ => return Err(crate::Error::UnknownFormat(format.to_string())), + }; + + Ok(Self { kind }) + } + + /// Decode one whole frame. + pub fn decode(&mut self, frame: &[u8], pts: Option) -> Result<()> { + match self.kind { + TrackKind::Avc3 { + ref mut split, + ref mut import, + } => { + // One whole access unit per call, so flush to emit it rather than + // waiting for the next start code. + let mut frames = split.decode(frame, pts)?; + frames.extend(split.flush(pts)?); + import.decode(frames)?; + } + TrackKind::Avc1 { + length_size, + ref mut import, + } => { + let pts = pts.ok_or(crate::codec::h264::Error::MissingTimestamp)?; + let frame = crate::codec::h264::avc1_frame(frame, length_size, pts)?; + import.decode([frame])?; + } + TrackKind::Hev1 { + ref mut split, + ref mut import, + } => { + let mut frames = split.decode(frame, pts)?; + frames.extend(split.flush(pts)?); + import.decode(frames)?; + } + TrackKind::Av01 { + ref mut split, + ref mut import, + } => { + let mut frames = split.decode(frame, pts)?; + frames.extend(split.flush(pts)?); + import.decode(frames)?; + } + TrackKind::Vp8(ref mut import) => import.decode(frame, pts)?, + TrackKind::Vp9(ref mut import) => import.decode(frame, pts)?, + TrackKind::Aac(ref mut import) => import.decode(frame, pts)?, + TrackKind::Opus(ref mut import) => import.decode(frame, pts)?, + } + + Ok(()) + } + + /// Finish the importer, flushing any buffered data. + pub fn finish(&mut self) -> Result<()> { + match self.kind { + TrackKind::Avc3 { ref mut import, .. } => import.finish(), + TrackKind::Avc1 { ref mut import, .. } => import.finish(), + TrackKind::Hev1 { ref mut import, .. } => import.finish(), + TrackKind::Av01 { ref mut import, .. } => import.finish(), + TrackKind::Vp8(ref mut import) => import.finish(), + TrackKind::Vp9(ref mut import) => import.finish(), + TrackKind::Aac(ref mut import) => import.finish(), + TrackKind::Opus(ref mut import) => import.finish(), + } + } + + /// Close the current group and open the next one at `sequence`. + pub fn seek(&mut self, sequence: u64) -> Result<()> { + match self.kind { + TrackKind::Avc3 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.seek(sequence) + } + TrackKind::Avc1 { ref mut import, .. } => import.seek(sequence), + TrackKind::Hev1 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.seek(sequence) + } + TrackKind::Av01 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.seek(sequence) + } + TrackKind::Vp8(ref mut import) => import.seek(sequence), + TrackKind::Vp9(ref mut import) => import.seek(sequence), + TrackKind::Aac(ref mut import) => import.seek(sequence), + TrackKind::Opus(ref mut import) => import.seek(sequence), + } + } + + /// A watch-only handle to the track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + match self.kind { + TrackKind::Avc3 { ref import, .. } => import.demand(), + TrackKind::Avc1 { ref import, .. } => import.demand(), + TrackKind::Hev1 { ref import, .. } => import.demand(), + TrackKind::Av01 { ref import, .. } => import.demand(), + TrackKind::Vp8(ref import) => import.demand(), + TrackKind::Vp9(ref import) => import.demand(), + TrackKind::Aac(ref import) => import.demand(), + TrackKind::Opus(ref import) => import.demand(), + } + } + + /// The name of the track this importer publishes. + pub fn name(&self) -> String { + self.demand().name().to_string() + } +} + +// Lift an already-built opus importer into a `Track` so callers that build their +// config out-of-band (e.g. moq-gst, which constructs `opus::Config` from gstreamer +// caps instead of an OpusHead buffer) can keep using `.into()`. +impl From> for Track { + fn from(opus: crate::codec::opus::Import) -> Self { + Self { + kind: TrackKind::Opus(opus), + } + } +} + +impl From> for Track { + fn from(aac: crate::codec::aac::Import) -> Self { + Self { + kind: TrackKind::Aac(aac), + } + } +} + +enum TrackStreamKind { + /// H.264 in avc3 wire shape (Annex-B with inline SPS/PPS). The split owns + /// byte parsing; the import publishes. + Avc3 { + split: crate::codec::h264::Split, + import: crate::codec::h264::Import, + }, + Hev1 { + split: crate::codec::h265::Split, + import: crate::codec::h265::Import, + }, + Av01 { + split: crate::codec::av1::Split, + import: crate::codec::av1::Import, + }, +} + +/// A single-codec importer for a raw byte stream with unknown frame boundaries. +/// +/// Use this when the caller does not know the frame boundaries (piped Annex-B +/// H.264, an fMP4 reader, …); the importer infers them. +pub struct TrackStream { + kind: TrackStreamKind, +} + +impl TrackStream { + /// Create an importer that publishes a single codec onto an existing track. + /// + /// The caller mints the track with + /// [`BroadcastProducer::create_track`](moq_net::BroadcastProducer::create_track) (or + /// [`unique_track`](crate::import::unique_track)) and hands it here; frames are stamped at + /// the legacy microsecond timescale. + pub fn new(track: moq_net::TrackProducer, catalog: crate::catalog::Producer, format: &str) -> Result { + // Only the self-delimiting codecs can be recovered from a raw byte stream. + let kind = match format { + "avc3" | "h264" => TrackStreamKind::Avc3 { + split: crate::codec::h264::Split::new(), + import: crate::codec::h264::Import::new(track, catalog), + }, + "hev1" => TrackStreamKind::Hev1 { + split: crate::codec::h265::Split::new(), + import: crate::codec::h265::Import::new(track, catalog), + }, + "av01" | "av1" | "av1c" | "av1C" => TrackStreamKind::Av01 { + split: crate::codec::av1::Split::new(), + import: crate::codec::av1::Import::new(track, catalog), + }, + _ => return Err(crate::Error::UnknownFormat(format.to_string())), + }; + + Ok(Self { kind }) + } + + /// Initialize the importer with the given buffer and populate the broadcast. + /// + /// This is not required for self-describing formats like AVC3. + pub fn initialize(&mut self, data: &[u8]) -> Result<()> { + match self.kind { + TrackStreamKind::Avc3 { + ref mut split, + ref mut import, + } => { + import.initialize(data)?; + let frames = split.decode(data, None)?; + import.decode(frames)?; + } + TrackStreamKind::Hev1 { + ref mut split, + ref mut import, + } => { + import.initialize(data)?; + let frames = split.decode(data, None)?; + import.decode(frames)?; + } + TrackStreamKind::Av01 { + ref mut split, + ref mut import, + } => { + import.initialize(data)?; + // av1C (leading 0x81) is an out-of-band config record, not an OBU + // stream; read for config above and dropped here. + let frames = if data.len() >= 16 && data[0] == 0x81 { + Vec::new() + } else { + split.decode(data, None)? + }; + import.decode(frames)?; + } + } + + Ok(()) + } + + /// Decode a chunk of the byte stream. + pub fn decode(&mut self, data: &[u8]) -> Result<()> { + match self.kind { + TrackStreamKind::Avc3 { + ref mut split, + ref mut import, + } => { + let frames = split.decode(data, None)?; + import.decode(frames) + } + TrackStreamKind::Hev1 { + ref mut split, + ref mut import, + } => { + let frames = split.decode(data, None)?; + import.decode(frames) + } + TrackStreamKind::Av01 { + ref mut split, + ref mut import, + } => { + let frames = split.decode(data, None)?; + import.decode(frames) + } + } + } + + /// Finish the importer, flushing any buffered data. + pub fn finish(&mut self) -> Result<()> { + match self.kind { + TrackStreamKind::Avc3 { + ref mut split, + ref mut import, + } => { + let tail = split.flush(None)?; + import.decode(tail)?; + import.finish() + } + TrackStreamKind::Hev1 { + ref mut split, + ref mut import, + } => { + let tail = split.flush(None)?; + import.decode(tail)?; + import.finish() + } + TrackStreamKind::Av01 { + ref mut split, + ref mut import, + } => { + let tail = split.flush(None)?; + import.decode(tail)?; + import.finish() + } + } + } + + /// Close the current group and open the next one at `sequence`. + pub fn seek(&mut self, sequence: u64) -> Result<()> { + match self.kind { + TrackStreamKind::Avc3 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.seek(sequence) + } + TrackStreamKind::Hev1 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.seek(sequence) + } + TrackStreamKind::Av01 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.seek(sequence) + } + } + } + + /// A watch-only handle to the track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + match self.kind { + TrackStreamKind::Avc3 { ref import, .. } => import.demand(), + TrackStreamKind::Hev1 { ref import, .. } => import.demand(), + TrackStreamKind::Av01 { ref import, .. } => import.demand(), + } + } + + /// The name of the track this importer publishes. + pub fn name(&self) -> String { + self.demand().name().to_string() + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::container::Timestamp; + + fn opus_head() -> Vec { + let mut head = Vec::with_capacity(19); + head.extend_from_slice(b"OpusHead"); + head.push(1); + head.push(2); + head.extend_from_slice(&0u16.to_le_bytes()); + head.extend_from_slice(&48000u32.to_le_bytes()); + head.extend_from_slice(&0u16.to_le_bytes()); + head.push(0); + head + } + + fn h264_init() -> Vec { + let mut init = Vec::new(); + init.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); + init.extend_from_slice(&[ + 0x67, 0x64, 0x00, 0x1f, 0xac, 0x24, 0x84, 0x01, 0x40, 0x16, 0xec, 0x04, 0x40, 0x00, 0x00, 0x03, 0x00, 0x40, + 0x00, 0x00, 0x0c, 0x23, 0xc6, 0x0c, 0x92, + ]); + init.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); + init.extend_from_slice(&[0x68, 0xee, 0x32, 0xc8, 0xb0]); + init + } + + fn new_broadcast() -> (moq_net::BroadcastProducer, crate::catalog::Producer) { + let mut broadcast = moq_net::Broadcast::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + (broadcast, catalog) + } + + #[tokio::test(start_paused = true)] + async fn existing_track_opus_uses_existing_name() { + let (mut broadcast, catalog) = new_broadcast(); + // The importer accepts the reserved track, setting its (microsecond) timescale. + let request = broadcast.create_track(moq_net::Track::new("requested-audio")).unwrap(); + let mut import = Track::new(request, catalog.clone(), "opus", &opus_head()).unwrap(); + + assert_eq!(import.name(), "requested-audio"); + let snapshot = catalog.snapshot(); + assert!(snapshot.audio.renditions.contains_key("requested-audio")); + assert!(!snapshot.audio.renditions.contains_key("0.opus")); + + // Frame delivery and the accepted timescale are covered by `opus_import_delivers_frames`. + import + .decode(b"opus payload", Some(Timestamp::from_micros(1_000).unwrap())) + .unwrap(); + import.finish().unwrap(); + } + + #[tokio::test(start_paused = true)] + async fn unique_track_opus_attaches_catalog_and_retires_on_drop() { + let (mut broadcast, catalog) = new_broadcast(); + + // A freshly reserved track attaches its catalog rendition on init. + let name = broadcast.unique_name(".opus"); + let request = broadcast.create_track(moq_net::Track::new(name)).unwrap(); + let mut import = Track::new(request, catalog.clone(), "opus", &opus_head()).unwrap(); + + assert_eq!(import.name(), "0.opus"); + assert!(catalog.snapshot().audio.renditions.contains_key("0.opus")); + + import + .decode(b"opus payload", Some(Timestamp::from_micros(2_000).unwrap())) + .unwrap(); + import.finish().unwrap(); + + // Dropping the importer retires its rendition from the shared catalog. + drop(import); + assert!(!catalog.snapshot().audio.renditions.contains_key("0.opus")); + } + + #[tokio::test(start_paused = true)] + async fn opus_import_delivers_frames() { + let (mut broadcast, catalog) = new_broadcast(); + let track = broadcast.create_track(moq_net::Track::new("audio")).unwrap(); + let subscriber = track.consume(); + + let config = crate::codec::opus::Config { + sample_rate: 48_000, + channel_count: 2, + }; + let mut import = crate::codec::opus::Import::new(track, catalog.clone(), config).unwrap(); + assert!(catalog.snapshot().audio.renditions.contains_key("audio")); + + let mut media = crate::container::Consumer::new(subscriber, crate::catalog::hang::Container::Legacy); + + let payload = b"opus payload".to_vec(); + import + .decode(&payload, Some(Timestamp::from_micros(1_000).unwrap())) + .unwrap(); + + let frame = tokio::time::timeout(Duration::from_secs(1), media.read()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(frame.payload, payload); + assert_eq!(frame.timestamp, Timestamp::from_micros(1_000).unwrap()); + + import.finish().unwrap(); + } + + #[tokio::test(start_paused = true)] + async fn existing_track_h264_uses_existing_name_in_catalog() { + let (mut broadcast, catalog) = new_broadcast(); + let request = broadcast.create_track(moq_net::Track::new("camera")).unwrap(); + + let import = Track::new(request, catalog.clone(), "avc3", &h264_init()).unwrap(); + + assert_eq!(import.name(), "camera"); + let snapshot = catalog.snapshot(); + let video = snapshot.video.renditions.get("camera").unwrap(); + assert_eq!(video.coded_width, Some(1280)); + assert_eq!(video.coded_height, Some(720)); + assert!(!snapshot.video.renditions.contains_key("0.avc3")); + } + + /// A changed key frame just updates the rendition in place; there are no fixed + /// tracks to reject a reconfiguration, so the second key frame succeeds. + #[tokio::test(start_paused = true)] + async fn reconfiguration_updates_in_place() { + let (mut broadcast, catalog) = new_broadcast(); + let request = broadcast.create_track(moq_net::Track::new("video")).unwrap(); + let mut import = Track::new(request, catalog, "vp8", &[]).unwrap(); + + import + .decode( + &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00], + Some(Timestamp::from_micros(0).unwrap()), + ) + .unwrap(); + + import + .decode( + &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01], + Some(Timestamp::from_micros(33_000).unwrap()), + ) + .unwrap(); + } +} diff --git a/rs/moq-mux/src/lib.rs b/rs/moq-mux/src/lib.rs index 460237d32..6d1242ab5 100644 --- a/rs/moq-mux/src/lib.rs +++ b/rs/moq-mux/src/lib.rs @@ -21,7 +21,6 @@ pub mod codec; pub mod container; mod error; pub mod import; -mod track_provider; pub use clock::Clock; pub use error::*; diff --git a/rs/moq-mux/src/track_provider.rs b/rs/moq-mux/src/track_provider.rs deleted file mode 100644 index 4b0b5b01e..000000000 --- a/rs/moq-mux/src/track_provider.rs +++ /dev/null @@ -1,34 +0,0 @@ -pub(crate) enum TrackProvider { - Unique { - broadcast: moq_net::BroadcastProducer, - suffix: &'static str, - }, - Fixed(moq_net::TrackProducer), -} - -impl TrackProvider { - pub(crate) fn unique(broadcast: moq_net::BroadcastProducer, suffix: &'static str) -> Self { - Self::Unique { broadcast, suffix } - } - - pub(crate) fn fixed(track: moq_net::TrackProducer) -> Self { - Self::Fixed(track) - } - - pub(crate) fn is_fixed(&self) -> bool { - matches!(self, Self::Fixed(_)) - } - - pub(crate) fn set_suffix(&mut self, next: &'static str) { - if let Self::Unique { suffix, .. } = self { - *suffix = next; - } - } - - pub(crate) fn create(&mut self) -> anyhow::Result { - match self { - Self::Unique { broadcast, suffix } => Ok(broadcast.unique_track(suffix)?), - Self::Fixed(track) => Ok(track.clone()), - } - } -} diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index eebf97c75..69dc661ed 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -125,6 +125,12 @@ impl BroadcastProducer { /// Generates names like `0{suffix}`, `1{suffix}`, etc. and picks the first /// one not already used in this broadcast. pub fn unique_track(&mut self, suffix: &str) -> Result { + let name = self.unique_name(suffix); + self.create_track(Track { name, priority: 0 }) + } + + /// Generate a unique track name from a suffix without creating the track. + pub fn unique_name(&self, suffix: &str) -> String { let state = self.state.read(); let mut name = String::new(); for i in 0u32.. { @@ -133,9 +139,7 @@ impl BroadcastProducer { break; } } - drop(state); - - self.create_track(Track { name, priority: 0 }) + name } /// Create a dynamic producer that handles on-demand track requests from consumers. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 02e8fed64..9218824da 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -340,6 +340,19 @@ impl TrackProducer { } } + /// The track name. + pub fn name(&self) -> &str { + &self.info.name + } + + /// A cloneable watch-only handle to subscriber demand. + pub fn demand(&self) -> TrackDemand { + TrackDemand { + name: self.info.name.clone(), + state: self.state.weak(), + } + } + /// Block until there are no active consumers. pub async fn unused(&self) -> Result<()> { self.state @@ -427,6 +440,45 @@ pub(crate) struct TrackWeak { state: kio::Weak, } +/// A cloneable, watch-only handle to a track's subscriber demand. +#[derive(Clone)] +pub struct TrackDemand { + name: String, + state: kio::Weak, +} + +impl TrackDemand { + /// The track name this handle is bound to. + pub fn name(&self) -> &str { + &self.name + } + + /// Block until there is at least one active consumer. + pub async fn used(&self) -> Result<()> { + self.state + .used() + .await + .map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) + } + + /// Block until there are no active consumers. + pub async fn unused(&self) -> Result<()> { + self.state + .unused() + .await + .map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) + } + + /// Block until the track is closed or aborted, returning the cause. + pub async fn closed(&self) -> Error { + if let Some(state) = self.state.produce() { + state.closed().await; + } + + self.state.read().abort.clone().unwrap_or(Error::Dropped) + } +} + impl TrackWeak { pub fn is_closed(&self) -> bool { self.state.is_closed() @@ -477,6 +529,11 @@ impl std::ops::Deref for TrackConsumer { } impl TrackConsumer { + /// The track name this handle is bound to. + pub fn name(&self) -> &str { + &self.info.name + } + // A helper to automatically apply Dropped if the state is closed without an error. fn poll(&self, waiter: &kio::Waiter, f: F) -> Poll> where diff --git a/rs/moq-rtc/src/codec/h264.rs b/rs/moq-rtc/src/codec/h264.rs index fa31f081c..0c4aa605c 100644 --- a/rs/moq-rtc/src/codec/h264.rs +++ b/rs/moq-rtc/src/codec/h264.rs @@ -7,18 +7,17 @@ use crate::{Result, codec}; -/// Feeds str0m's Annex-B H.264 access units into a moq-mux avc3 importer. pub struct Bridge { - import: moq_mux::codec::h264::Import, + split: moq_mux::codec::h264::Split, + import: moq_mux::codec::h264::Import, } impl Bridge { - /// Publish an `.avc3` track on `broadcast`, registering it in `catalog`. - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: moq_mux::catalog::Producer) -> Result { - // Pin avc3 (Annex-B, inline SPS/PPS) up front: str0m always hands us that wire shape. - let import = - moq_mux::codec::h264::Import::new(broadcast, catalog).with_mode(moq_mux::codec::h264::Mode::Avc3)?; - Ok(Self { import }) + pub fn new(mut broadcast: moq_net::BroadcastProducer, catalog: moq_mux::catalog::Producer) -> Result { + let track = moq_mux::import::unique_track(&mut broadcast, ".avc3")?; + let import = moq_mux::codec::h264::Import::new(track, catalog); + let split = moq_mux::codec::h264::Split::new(); + Ok(Self { split, import }) } } @@ -26,9 +25,10 @@ impl codec::Bridge for Bridge { fn push(&mut self, frame: codec::Frame) -> Result<()> { let pts = moq_mux::container::Timestamp::from_micros(frame.timestamp_us) .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; - // str0m hands over one whole access unit per frame. - let mut payload = frame.payload; - self.import.decode_frame(&mut payload, Some(pts))?; + // str0m hands over one whole access unit per frame, so flush to emit it. + let mut frames = self.split.decode(&frame.payload, Some(pts))?; + frames.extend(self.split.flush(Some(pts))?); + self.import.decode(frames)?; Ok(()) } } diff --git a/rs/moq-rtc/src/codec/opus.rs b/rs/moq-rtc/src/codec/opus.rs index 700950da0..5c63222df 100644 --- a/rs/moq-rtc/src/codec/opus.rs +++ b/rs/moq-rtc/src/codec/opus.rs @@ -5,13 +5,11 @@ use crate::{Result, codec}; -/// Feeds str0m's Opus packets into a moq-mux Opus importer. pub struct Bridge { import: moq_mux::codec::opus::Import, } impl Bridge { - /// Publish an `.opus` track on `broadcast` at the negotiated sample rate / channel count. pub fn new( mut broadcast: moq_net::BroadcastProducer, catalog: moq_mux::catalog::Producer, @@ -22,8 +20,8 @@ impl Bridge { sample_rate, channel_count, }; - let track = broadcast.unique_track(".opus")?; - let import = moq_mux::codec::opus::Import::new_with_track(track, catalog, config)?; + let track = moq_mux::import::unique_track(&mut broadcast, ".opus")?; + let import = moq_mux::codec::opus::Import::new(track, catalog, config)?; Ok(Self { import }) } } @@ -32,8 +30,7 @@ impl codec::Bridge for Bridge { fn push(&mut self, frame: codec::Frame) -> Result<()> { let pts = moq_mux::container::Timestamp::from_micros(frame.timestamp_us) .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; - let mut payload = frame.payload; - self.import.decode(&mut payload, Some(pts))?; + self.import.decode(&frame.payload, Some(pts))?; Ok(()) } } diff --git a/rs/moq-rtc/src/codec/vp8.rs b/rs/moq-rtc/src/codec/vp8.rs index 559f68659..29f4ed28b 100644 --- a/rs/moq-rtc/src/codec/vp8.rs +++ b/rs/moq-rtc/src/codec/vp8.rs @@ -52,6 +52,7 @@ impl codec::Bridge for Bridge { timestamp: pts, payload: frame.payload, keyframe, + duration: None, }) .map_err(|err| crate::Error::Other(anyhow::anyhow!("vp8 track write failed: {err}")))?; Ok(()) diff --git a/rs/moq-rtc/src/codec/vp9.rs b/rs/moq-rtc/src/codec/vp9.rs index 535f8c921..e8f43145a 100644 --- a/rs/moq-rtc/src/codec/vp9.rs +++ b/rs/moq-rtc/src/codec/vp9.rs @@ -50,6 +50,7 @@ impl codec::Bridge for Bridge { timestamp: pts, payload: frame.payload, keyframe, + duration: None, }) .map_err(|err| crate::Error::Other(anyhow::anyhow!("vp9 track write failed: {err}")))?; Ok(()) diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs index d780114b7..13dfdfab6 100644 --- a/rs/moq-rtmp/src/server.rs +++ b/rs/moq-rtmp/src/server.rs @@ -876,7 +876,7 @@ impl Publisher { ); // Feed the FLV file header once up front; media tags follow per message. - importer.decode(&mut flv::file_header())?; + importer.decode(&flv::file_header())?; Ok(Self { importer }) } @@ -890,7 +890,7 @@ impl Publisher { "RTMP message body {} exceeds FLV's 24-bit tag size limit", body.len() ); - self.importer.decode(&mut flv::tag(tag_type, timestamp, body)) + self.importer.decode(&flv::tag(tag_type, timestamp, body)) } /// Flush any buffered media and close out the broadcast's open groups. @@ -1082,9 +1082,9 @@ mod tests { let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap(); let mut importer = FlvImport::new(broadcast.clone(), catalog); assert!(origin.publish_broadcast("live/cam0", broadcast.consume())); - importer.decode(&mut flv::file_header()).unwrap(); - importer.decode(&mut flv::tag(flv::TAG_VIDEO, 0, &vseq)).unwrap(); - importer.decode(&mut flv::tag(flv::TAG_VIDEO, 0, &vframe)).unwrap(); + importer.decode(&flv::file_header()).unwrap(); + importer.decode(&flv::tag(flv::TAG_VIDEO, 0, &vseq)).unwrap(); + importer.decode(&flv::tag(flv::TAG_VIDEO, 0, &vframe)).unwrap(); importer.finish().unwrap(); let mut server = Server::bind("127.0.0.1:0".parse().unwrap()).await.unwrap(); diff --git a/rs/moq-srt/src/ts.rs b/rs/moq-srt/src/ts.rs index 22e1c2d8c..5df51b58e 100644 --- a/rs/moq-srt/src/ts.rs +++ b/rs/moq-srt/src/ts.rs @@ -8,7 +8,6 @@ //! it back to MPEG-TS for an SRT caller (VLC, ffmpeg) to play. use bytes::Bytes; -use moq_mux::catalog::hang::Extra; use moq_mux::container::{Frame, ts}; use moq_net::{Broadcast, OriginConsumer, OriginProducer}; @@ -24,7 +23,7 @@ use crate::Result; pub struct Publisher { /// Owns a clone of the broadcast producer, so the broadcast stays announced /// (and writable) for the publisher's lifetime. - importer: ts::Import, + importer: ts::Import, } impl Publisher { @@ -51,8 +50,8 @@ impl Publisher { /// /// `decode` drains `data` fully, buffering any partial trailing packet in /// its own internal scratch, so there's nothing to retain here. - pub fn feed(&mut self, mut data: Bytes) -> Result<()> { - Ok(self.importer.decode(&mut data)?) + pub fn feed(&mut self, data: Bytes) -> Result<()> { + Ok(self.importer.decode(&data)?) } /// Flush any buffered media and close out the broadcast's open groups. diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index f2851bea9..8e4790406 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -25,27 +25,32 @@ const DEFAULT_FRAMERATE: u32 = 30; /// trigger capture on demand. `moq_mux::codec::h264::Import` handles /// catalog registration and framing. pub struct Producer { - import: moq_mux::codec::h264::Import, + split: moq_mux::codec::h264::Split, + import: moq_mux::codec::h264::Import, } impl Producer { - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: moq_mux::catalog::Producer) -> Result { - let import = - moq_mux::codec::h264::Import::new(broadcast, catalog).with_mode(moq_mux::codec::h264::Mode::Avc3)?; - Ok(Self { import }) + pub fn new(mut broadcast: moq_net::BroadcastProducer, catalog: moq_mux::catalog::Producer) -> Result { + let track = moq_mux::import::unique_track(&mut broadcast, ".avc3")?; + let import = moq_mux::codec::h264::Import::new(track, catalog); + let split = moq_mux::codec::h264::Split::new(); + Ok(Self { split, import }) } - /// The underlying track producer, eagerly created by avc3 mode. Clone it - /// to watch subscription state via [`used`](moq_net::TrackProducer::used) / - /// [`unused`](moq_net::TrackProducer::unused). - pub fn track(&self) -> Option<&moq_net::TrackProducer> { - self.import.track() + /// A watch-only handle to the track's subscriber demand, created eagerly so + /// subscription state is observable before any frames arrive. Watch it via + /// [`used`](moq_net::TrackDemand::used) / [`unused`](moq_net::TrackDemand::unused). + pub fn demand(&self) -> moq_net::TrackDemand { + self.import.demand() } /// Publish already-encoded Annex-B packets at the given timestamp. pub fn publish(&mut self, packets: Vec, timestamp: Timestamp) -> Result<(), Error> { - for mut packet in packets { - self.import.decode_frame(&mut packet, Some(timestamp))?; + for packet in packets { + // The encoder emits one whole access unit per packet, so flush to emit it. + let mut frames = self.split.decode(&packet, Some(timestamp))?; + frames.extend(self.split.flush(Some(timestamp))?); + self.import.decode(frames)?; } Ok(()) } @@ -94,14 +99,11 @@ pub async fn publish_capture( } let producer = Producer::new(broadcast, catalog)?; - let track = producer - .track() - .cloned() - .ok_or_else(|| Error::Codec(anyhow::anyhow!("avc3 track was not created")))?; + let demand = producer.demand(); let gate = Gate::new(); - // ffmpeg capture + encode is blocking; keep it off the async runtime. + // Camera capture + encode is blocking; keep it off the async runtime. let worker_gate = gate.clone(); let mut worker = tokio::task::spawn_blocking(move || capture_loop(producer, capture, encode, worker_gate, clock)); @@ -109,7 +111,7 @@ pub async fn publish_capture( // Surface a capture/encode failure (e.g. camera open) promptly. res = &mut worker => res.map_err(|e| Error::Codec(anyhow::anyhow!("capture task: {e}")))?, // The broadcast was dropped: stop the worker and wait for it to flush. - () = monitor_demand(&track, &gate) => { + () = monitor_demand(&demand, &gate) => { gate.close(); worker .await @@ -120,13 +122,13 @@ pub async fn publish_capture( /// Toggle the gate as viewers subscribe and unsubscribe. Returns once the /// track stops being announced (broadcast dropped / aborted). -async fn monitor_demand(track: &moq_net::TrackProducer, gate: &Gate) { +async fn monitor_demand(demand: &moq_net::TrackDemand, gate: &Gate) { loop { - match track.used().await { + match demand.used().await { Ok(()) => gate.set_active(true), Err(err) => return log_track_ended(err), } - match track.unused().await { + match demand.unused().await { Ok(()) => gate.set_active(false), Err(err) => return log_track_ended(err), } diff --git a/rs/moq-video/src/error.rs b/rs/moq-video/src/error.rs index 6b24b203f..d14ee3edc 100644 --- a/rs/moq-video/src/error.rs +++ b/rs/moq-video/src/error.rs @@ -29,7 +29,11 @@ pub enum Error { #[error("invalid framerate: {0} (must be non-zero)")] InvalidFramerate(u32), - /// moq-mux codec/transport error (H.264 import, catalog). + /// moq-mux codec/container error (H.264 import, catalog). + #[error(transparent)] + Mux(#[from] moq_mux::Error), + + /// Ad-hoc encode/capture error. #[error(transparent)] Codec(#[from] anyhow::Error), From 791f82f9cfbe8a6c232821ea0deba7fa9a19d279 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:28:31 -0700 Subject: [PATCH 16/34] build(deps-dev): bump the uv group across 1 directory with 3 updates (#1858) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 74 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/uv.lock b/uv.lock index 71a5ff6a5..13c62db66 100644 --- a/uv.lock +++ b/uv.lock @@ -50,26 +50,26 @@ wheels = [ [[package]] name = "maturin" -version = "1.13.3" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/612d23d33ec21b9ae7ece7b3f0dd5f9dfd57b4009e9d2938165869ebd6ae/maturin-1.13.3.tar.gz", hash = "sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079", size = 357934, upload-time = "2026-05-11T07:43:39.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/d0/b7c8b7778cc44df3efbc96eb23acaa995e06ea1a60eb9b02f29858fcbd08/maturin-1.14.0.tar.gz", hash = "sha256:f7f82a6aca4a6c402bf00b99200be199d4874d04b9b9e74e825726a3478bba7f", size = 367010, upload-time = "2026-06-12T00:13:30.811Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/66/18c2aaac0b2a5dea9f1db5984ce83b905ad205cfc7c02d0091e707c0c2e7/maturin-1.13.3-py3-none-linux_armv6l.whl", hash = "sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c", size = 10190971, upload-time = "2026-05-11T07:43:10.431Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/26a988d092e4fd6a9523d46d44400a46cad7cdf3fd206ce702240c748aee/maturin-1.13.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323", size = 19716714, upload-time = "2026-05-11T07:43:36.911Z" }, - { url = "https://files.pythonhosted.org/packages/82/5c/f3fd0e184255d9fc7e272c62af3dfa84c617b2577ef83af9ce615f5279cc/maturin-1.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0", size = 10194726, upload-time = "2026-05-11T07:43:07.05Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e1/f4edb69fb647b77c4769a9bfd4d6fb62961e653d164bc277ecdffac3ab61/maturin-1.13.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11", size = 10172781, upload-time = "2026-05-11T07:43:40.796Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7d/a1be934690cdcc3c6609769ceaad322ab7501c2ee5bafcac1b14d609e403/maturin-1.13.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f", size = 10682670, upload-time = "2026-05-11T07:43:13.132Z" }, - { url = "https://files.pythonhosted.org/packages/18/f5/372ae19b72ce8f6e37e5864ae4dc5b252ee9fce0619ccc3aa366aa3a7f97/maturin-1.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018", size = 10060363, upload-time = "2026-05-11T07:43:21.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/5b/c68340cca09368af0df80965dfabed4234205a492a93da00793c7b9aae20/maturin-1.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3", size = 10017551, upload-time = "2026-05-11T07:43:33.916Z" }, - { url = "https://files.pythonhosted.org/packages/28/1e/f90fb2b000bad9e6d850cd5afb88b2f1e2a279cfb4de02ea40078484690e/maturin-1.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a", size = 13301712, upload-time = "2026-05-11T07:43:26.492Z" }, - { url = "https://files.pythonhosted.org/packages/be/58/1670f68a8f04ccd7b90df11047bd9a046585310e84e1967cc9849cd1c5a3/maturin-1.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff", size = 10946765, upload-time = "2026-05-11T07:43:16.135Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/00c955c2ef134817b1a7bdaa76b0309e9c5291eb17d9ff88069eecd08bc2/maturin-1.13.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273", size = 10388661, upload-time = "2026-05-11T07:43:18.727Z" }, - { url = "https://files.pythonhosted.org/packages/97/c6/cbf8a51dde19c19aeba0d9b075095a2effb9b31fd312b1aae3ac79f8aea2/maturin-1.13.3-py3-none-win32.whl", hash = "sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b", size = 8901838, upload-time = "2026-05-11T07:43:23.76Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ff/c6a50a59dc8313097d43ac5f4d74df6a500c8cb62b0dc9e054f53e203a48/maturin-1.13.3-py3-none-win_amd64.whl", hash = "sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6", size = 10340801, upload-time = "2026-05-11T07:43:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/6c/93/e32e79333f0902ba292b996f504f5f06be59587f7d02ab8d5ed1e3066445/maturin-1.13.3-py3-none-win_arm64.whl", hash = "sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b", size = 9706562, upload-time = "2026-05-11T07:43:31.743Z" }, + { url = "https://files.pythonhosted.org/packages/88/51/49367dcd8f6ec139e69ef0c695c8ff5075223673382101812b4affa53216/maturin-1.14.0-py3-none-linux_armv6l.whl", hash = "sha256:019ea3ec7e71f4c9759a367d4d21022ed5a3a621a2ce123abf3fb114ab3711ca", size = 10204135, upload-time = "2026-06-12T00:13:34.308Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2a/487ce56c838d25e0ce64350e75ec4e3dc89544c0a6233221c229d6aa1a84/maturin-1.14.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6948a10f5f3470b791f79319be51debdd8bfd1778b36f2409f98e1314bc3859b", size = 19736800, upload-time = "2026-06-12T00:13:40.456Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a5/12f2efc18f419edce3282a93629cba16278bb502135dac95cd04ef7c2eae/maturin-1.14.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1506e86b1e273a98074a62e281b13f27ac96f8cdef85f7f98d3e3589a9387a23", size = 10201144, upload-time = "2026-06-12T00:13:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/3789e72273fd8bc80c33a11c787634b3251c4989d7a7203a92438836d4ff/maturin-1.14.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:df10ce4f7ba97fd3423f624f39b94c888ae3e5b470642a91918e1ccec81282fd", size = 10182394, upload-time = "2026-06-12T00:13:13.693Z" }, + { url = "https://files.pythonhosted.org/packages/40/79/15957eb4e055597f217e6310963a9c1371372e63c5b4a3e30803365addd2/maturin-1.14.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:75bcd4468a7fe597652cc2980c6bb16ce4bb8c411e3eb85dac2c4418cef0e95a", size = 10616603, upload-time = "2026-06-12T00:13:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4b/d1822f88cd5e855640f0e10ee00c39b9be614c1ef2f827e9792332d94b9f/maturin-1.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d123337e817f8dfe23755d6760139c01104137bb63e9e20c289c547e25ec857", size = 10075309, upload-time = "2026-06-12T00:13:38.274Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/c1b160d2163e8784489285e82a5c811fdcef3e0704e35b34c1cfe1828de3/maturin-1.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:107f84110d890090a01bb1ecd01761fdfae925c23c659ba492c9b83dd179eab4", size = 10024058, upload-time = "2026-06-12T00:13:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/88a9d1872997d4535af10ebe79f550e834880bf613cf8e50b50d2d938e3b/maturin-1.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:9a84277aa907961cd47ad26fef1539e79efa30611972eaf7499606e773e991b2", size = 13302073, upload-time = "2026-06-12T00:13:29.027Z" }, + { url = "https://files.pythonhosted.org/packages/4a/13/3f6d28bb7b744558b9bc78c995c1855d7e5ff21ad475f46d9de5c3dab039/maturin-1.14.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:095714b2a904927e3c868a1c5d078257ff0443c5049f7623777352966768306e", size = 10863616, upload-time = "2026-06-12T00:13:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/39352d2b402efa3a7dd01d4ed197b301ea35eec10208ba2b8c649101f4df/maturin-1.14.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:20229d332f87166b930e4ca07cdbee8a1726f2eea87a337610aa25bba3ddf4b4", size = 10399943, upload-time = "2026-06-12T00:13:36.273Z" }, + { url = "https://files.pythonhosted.org/packages/58/77/641504541336240fef3836b2d15a785eaeb33c941fb118513c267dd70840/maturin-1.14.0-py3-none-win32.whl", hash = "sha256:4ba1e3c3f33609f461d587b7549104c81a15fd6d42ba63a73cea9376a1e9876e", size = 8905117, upload-time = "2026-06-12T00:13:18.38Z" }, + { url = "https://files.pythonhosted.org/packages/02/4a/ca247a0c43069b2f48cf783c5b13c3a9eb92c8f596dc7fbdb9f75fea4414/maturin-1.14.0-py3-none-win_amd64.whl", hash = "sha256:cb09a313f097adeb4dda0082277871a28d1bd26615dbadab42e6234b6df6fe69", size = 10309099, upload-time = "2026-06-12T00:13:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a4/f14a3f6086cc3caaa90d12e832e4aa41de771c310041959f0d35dd4efe17/maturin-1.14.0-py3-none-win_arm64.whl", hash = "sha256:8c1a8188195f5b6ce1aab99ae2d92e342900298f901456b43ca028947fd3b288", size = 9719100, upload-time = "2026-06-12T00:13:24.741Z" }, ] [[package]] @@ -163,7 +163,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -174,9 +174,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] [[package]] @@ -195,27 +195,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.16" +version = "0.15.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, - { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, - { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, - { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, - { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, - { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, - { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, - { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, - { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] [[package]] From 97e791459ae6e415cecbeda02b8d06a094a42a90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:29:07 -0700 Subject: [PATCH 17/34] build(deps): bump the github-actions group across 1 directory with 3 updates (#1832) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check.yml | 2 +- .github/workflows/release-kt.yml | 2 +- .github/workflows/update-flake.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 88076bcf0..1d3ee492d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -53,7 +53,7 @@ jobs: - if: runner.environment == 'github-hosted' uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main - if: runner.environment == 'github-hosted' - uses: DeterminateSystems/flake-checker-action@9ee1c5473f1abdfb299f6c4f0fab813147c97fe3 # main + uses: DeterminateSystems/flake-checker-action@a0f068d37b542f3150564fbf1164fec2d958ee11 # main # GitHub-hosted fallback caches Rust via the Actions cache. The self-hosted # box runs the heavy Rust CI (clippy/doc/test) as plain cargo, reusing a diff --git a/.github/workflows/release-kt.yml b/.github/workflows/release-kt.yml index de799b709..19e459e97 100644 --- a/.github/workflows/release-kt.yml +++ b/.github/workflows/release-kt.yml @@ -151,7 +151,7 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Download per-target libs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml index 2a7099acb..cd96544c1 100644 --- a/.github/workflows/update-flake.yml +++ b/.github/workflows/update-flake.yml @@ -29,7 +29,7 @@ jobs: run: dirname "$(command -v nix)" >> "$GITHUB_PATH" - name: Update flake.lock - uses: DeterminateSystems/update-flake-lock@b83e0671a67dfd774680fb1beaa1497ef7e58bfc # main + uses: DeterminateSystems/update-flake-lock@fd9359ac79d0e912f1b4b947a48470b3e2799b56 # main with: pr-title: "Update flake.lock" pr-labels: | From 1dac0a5bc68e1aa042b56f67d3fe9ad3867b0bea Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Jun 2026 14:04:36 -0700 Subject: [PATCH 18/34] fix(moq-mux): codec/container correctness fixes from #1918 review (#1923) (#1925) Co-authored-by: Claude Opus 4.8 --- rs/moq-mux/src/catalog/filter.rs | 49 +++++- rs/moq-mux/src/catalog/msf/consumer.rs | 6 +- rs/moq-mux/src/catalog/msf/mod.rs | 3 - rs/moq-mux/src/catalog/target.rs | 44 ++++- rs/moq-mux/src/codec/aac/mod.rs | 174 ++++++++++--------- rs/moq-mux/src/codec/h264/split.rs | 40 +++++ rs/moq-mux/src/codec/h265/split.rs | 38 ++++ rs/moq-mux/src/container/consumer.rs | 99 ++++++++++- rs/moq-mux/src/container/fmp4/import_test.rs | 42 +++++ rs/moq-mux/src/container/fmp4/mod.rs | 18 ++ rs/moq-mux/src/container/producer.rs | 43 +++-- 11 files changed, 445 insertions(+), 111 deletions(-) diff --git a/rs/moq-mux/src/catalog/filter.rs b/rs/moq-mux/src/catalog/filter.rs index d032397ed..d5ac6e956 100644 --- a/rs/moq-mux/src/catalog/filter.rs +++ b/rs/moq-mux/src/catalog/filter.rs @@ -131,11 +131,21 @@ impl Stream for Filter { Poll::Ready(Ok((emit, epoch))) => { self.last_epoch = epoch; self.fresh_input = false; + // End with upstream: if this is the final snapshot (inner already EOF'd), + // drop the retained input so a later filter change can't revive the stream + // after it has emitted its last value. + if inner_eof { + self.last_input = None; + } Poll::Ready(Ok(Some(emit))) } Poll::Ready(Err(_)) => Poll::Ready(Ok(None)), Poll::Pending => { - if inner_eof && self.last_input.is_none() { + // EOF is terminal: once `inner` is exhausted and there's nothing fresh to + // emit, finish and drop the retained input so a post-EOF setter can't make + // the closure emit again (a still-pending snapshot returns Ready above). + if inner_eof { + self.last_input = None; Poll::Ready(Ok(None)) } else { Poll::Pending @@ -203,6 +213,22 @@ mod test { } } + /// A still-live stream: yields its snapshot once, then parks (never EOFs). Models a + /// real upstream that stays open so post-snapshot retargeting is exercised without + /// tripping the end-with-upstream path. + struct Live(Option); + + impl Stream for Live { + type Ext = (); + + fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { + match self.0.take() { + Some(catalog) => Poll::Ready(Ok(Some(catalog))), + None => Poll::Pending, + } + } + } + fn h264(name: &str) -> (String, VideoConfig) { let mut config = VideoConfig::new(H264 { profile: 0x42, @@ -293,10 +319,29 @@ mod test { } #[test] - fn set_video_after_snapshot_reemits() { + fn ends_after_upstream_eof() { let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); let mut f = Filter::new(Once(Some(snapshot))); + // First poll emits the filtered snapshot. + assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(Some(_))))); + // Upstream is exhausted, so the stream ends rather than parking forever. + assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + + // EOF is terminal: a filter change after the end must not revive the stream. + f.set_video(FilterVideo { + name: Some("hi".into()), + ..Default::default() + }); + assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + } + + #[test] + fn set_video_after_snapshot_reemits() { + // A live (not-yet-EOF) upstream, so the retarget re-applies to the retained snapshot. + let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); + let mut f = Filter::new(Live(Some(snapshot))); + let first = match f.poll_next(&kio::Waiter::noop()) { Poll::Ready(Ok(Some(c))) => c, other => panic!("got {other:?}"), diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index d0d3e6ca6..72e0beb14 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -292,11 +292,11 @@ fn derive_from_codec_config(track: &moq_msf::Track, codec: &AudioCodec, init: by let mut buf = init; match codec { AudioCodec::AAC(_) => { + // AudioSpecificConfig carries valid variable-length extensions (SBR/PS) after + // the core fields, so `parse` consumes the whole buffer; bytes past the core + // fields are legitimate config, not trailing junk. let cfg = crate::codec::aac::Config::parse(&mut buf).map_err(|_| Error::MalformedAac(track.name.clone()))?; - if buf.has_remaining() { - return Err(Error::AacTrailingBytes(track.name.clone()).into()); - } Ok(DerivedAudio { sample_rate: cfg.sample_rate, channel_count: cfg.channel_count, diff --git a/rs/moq-mux/src/catalog/msf/mod.rs b/rs/moq-mux/src/catalog/msf/mod.rs index ebe5ce0d2..065accf74 100644 --- a/rs/moq-mux/src/catalog/msf/mod.rs +++ b/rs/moq-mux/src/catalog/msf/mod.rs @@ -50,9 +50,6 @@ pub enum Error { #[error("MSF audio track {0:?} has malformed OpusHead")] MalformedOpus(String), - #[error("MSF audio track {0:?} AudioSpecificConfig has trailing bytes")] - AacTrailingBytes(String), - #[error("MSF audio track {0:?} OpusHead has trailing bytes")] OpusTrailingBytes(String), diff --git a/rs/moq-mux/src/catalog/target.rs b/rs/moq-mux/src/catalog/target.rs index 61950e84e..5356660b4 100644 --- a/rs/moq-mux/src/catalog/target.rs +++ b/rs/moq-mux/src/catalog/target.rs @@ -143,6 +143,12 @@ impl Stream for Target { Poll::Ready(Ok((emit, epoch))) => { self.last_epoch = epoch; self.fresh_input = false; + // End with upstream: if this is the final snapshot (inner already EOF'd), + // drop the retained input so a later retarget can't revive the stream after + // it has emitted its last value. + if inner_eof { + self.last_input = None; + } Poll::Ready(Ok(Some(emit))) } Poll::Ready(Err(_)) => { @@ -150,7 +156,11 @@ impl Stream for Target { Poll::Ready(Ok(None)) } Poll::Pending => { - if inner_eof && self.last_input.is_none() { + // EOF is terminal: once `inner` is exhausted and there's nothing fresh to + // emit, finish and drop the retained input so a post-EOF retarget can't make + // the closure emit again (a still-pending snapshot returns Ready above). + if inner_eof { + self.last_input = None; Poll::Ready(Ok(None)) } else { Poll::Pending @@ -385,10 +395,42 @@ fn best_audio(renditions: &BTreeMap) -> String { #[cfg(test)] mod test { + use std::collections::BTreeMap; + use hang::catalog::{Container, H264, VideoConfig}; use super::*; + /// A one-shot stream: yields its snapshot once, then EOF. + struct Once(Option); + + impl Stream for Once { + type Ext = (); + + fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { + Poll::Ready(Ok(self.0.take())) + } + } + + /// Once upstream ends and the final selected snapshot is emitted, the stream ends + /// rather than parking forever waiting for a post-EOF retarget. + #[test] + fn ends_after_upstream_eof() { + let mut catalog = Catalog::default(); + catalog.video.renditions = BTreeMap::from_iter(vec![vid("only", 640, 360, 500_000)]); + + let mut t = Target::new(Once(Some(catalog))); + assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(Some(_))))); + assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + + // EOF is terminal: a retarget after the end must not revive the stream. + t.set_video(TargetVideo { + width: Some(320), + ..Default::default() + }); + assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + } + fn vid(name: &str, w: u32, h: u32, bitrate: u64) -> (String, VideoConfig) { let mut config = VideoConfig::new(H264 { profile: 0x42, diff --git a/rs/moq-mux/src/codec/aac/mod.rs b/rs/moq-mux/src/codec/aac/mod.rs index 0360f6c82..e2477911f 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -43,79 +43,45 @@ impl Config { /// Parse an AudioSpecificConfig buffer. /// /// Handles basic formats (object_type < 31), extended formats - /// (object_type == 31), and explicit sample rates (freq_index == 15). - /// Any SBR/PS extension bytes after the core fields are consumed. + /// (object_type == 31), and explicit sample rates (freq_index == 15). The + /// fields are bit-packed and not byte-aligned, so a bit reader is required: + /// with an explicit 24-bit rate the channelConfiguration lands mid-byte after + /// it. Any SBR/PS extension bits after the core fields are consumed. pub fn parse(buf: &mut T) -> Result { if buf.remaining() < 2 { return Err(Error::ConfigTooShort); } - // Read first byte - let b0 = buf.get_u8(); - let mut object_type = b0 >> 3; - let freq_index; + let mut reader = BitReader::new(buf); - let (profile, sample_rate, channel_count) = if object_type == 31 { - if buf.remaining() < 2 { - return Err(Error::ExtendedConfigTooShort); - } - // Extended format: next 6 bits are the extended object_type (32-63). - // Bits 5-7 of b0 are the first 3 bits of extended object_type. - let b_ext = buf.get_u8(); - // Bits 0-2 of b_ext are the last 3 bits of extended object_type. - let audio_object_type_ext = ((b0 & 0x07) << 3) | ((b_ext >> 5) & 0x07); - object_type = 32 + audio_object_type_ext; - // Bits 3-6 of b_ext are samplingFrequencyIndex (4 bits). - freq_index = (b_ext >> 1) & 0x0F; - // Bit 0 of b_ext is the first bit of channelConfiguration. - let channel_config_high = b_ext & 0x01; - - // Read next byte for rest of channelConfiguration. - if buf.remaining() < 1 { - return Err(Error::IncompleteConfig); - } - let b1 = buf.get_u8(); - // Bits 5-7 of b1 are the remaining 3 bits of channelConfiguration. - let channel_config = (channel_config_high << 3) | ((b1 >> 5) & 0x07); - - let sample_rate = sample_rate_from_index(freq_index, buf)?; - let channel_count = channel_count_from_config(channel_config); - - if buf.remaining() > 0 { - buf.advance(buf.remaining()); - } + // audioObjectType: 5 bits, escaped to 6 more when it reads 31. + let mut object_type = reader.read(5, Error::ConfigTooShort)? as u8; + if object_type == 31 { + object_type = 32 + reader.read(6, Error::ExtendedConfigTooShort)? as u8; + } - (object_type, sample_rate, channel_count) + // samplingFrequencyIndex: 4 bits; index 15 means an explicit 24-bit rate follows. + let freq_index = reader.read(4, Error::IncompleteConfig)? as u8; + let sample_rate = if freq_index == 15 { + reader.read(24, Error::ExplicitSampleRateTooShort)? } else { - // Standard format: bits 5-7 of b0 are first 3 bits of freq_index. - let mut freq_index_local = (b0 & 0x07) << 1; - - if buf.remaining() < 1 { - return Err(Error::IncompleteConfig); - } - let b1 = buf.get_u8(); - - // Complete frequency index (bit 7 of b1 is bit 0 of freq_index). - freq_index_local |= (b1 >> 7) & 0x01; - freq_index = freq_index_local; - - let channel_config = (b1 >> 3) & 0x0F; - - let sample_rate = sample_rate_from_index(freq_index, buf)?; - let channel_count = channel_count_from_config(channel_config); + *SAMPLE_RATES + .get(freq_index as usize) + .ok_or(Error::UnsupportedSampleRateIndex(freq_index))? + }; - // AudioSpecificConfig can have variable-length extensions (SBR, PS, - // etc.). We've already extracted the essential info; consume any - // remaining bytes to ensure the buffer is properly advanced. - if buf.remaining() > 0 { - buf.advance(buf.remaining()); - } + // channelConfiguration: 4 bits, immediately after the (possibly explicit) rate. + let channel_config = reader.read(4, Error::IncompleteConfig)? as u8; + let channel_count = channel_count_from_config(channel_config); - (object_type, sample_rate, channel_count) - }; + // AudioSpecificConfig can carry variable-length extensions (SBR, PS, etc.). + // We've extracted the essential fields; drain the rest so the buffer is advanced. + if buf.remaining() > 0 { + buf.advance(buf.remaining()); + } Ok(Self { - profile, + profile: object_type, sample_rate, channel_count, }) @@ -166,23 +132,48 @@ impl Config { } } -fn sample_rate_from_index(freq_index: u8, buf: &mut T) -> Result { - const SAMPLE_RATES: [u32; 13] = [ - 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, - ]; +/// The 13 standard AAC sampling frequencies, indexed by samplingFrequencyIndex +/// (ISO 14496-3 Table 1.18). Index 15 is the escape for an explicit 24-bit rate. +const SAMPLE_RATES: [u32; 13] = [ + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, +]; + +/// MSB-first bit reader that pulls bytes from a [`Buf`] on demand. +/// +/// AudioSpecificConfig is bit-packed: an explicit 24-bit sample rate pushes the +/// following channelConfiguration off byte boundaries, so the fields can't be +/// read a whole byte at a time. +struct BitReader<'a, T: Buf> { + buf: &'a mut T, + current: u8, + bits_left: u8, +} - if freq_index == 15 { - if buf.remaining() < 3 { - return Err(Error::ExplicitSampleRateTooShort); +impl<'a, T: Buf> BitReader<'a, T> { + fn new(buf: &'a mut T) -> Self { + Self { + buf, + current: 0, + bits_left: 0, } - let rate_bytes = [buf.get_u8(), buf.get_u8(), buf.get_u8()]; - return Ok(((rate_bytes[0] as u32) << 16) | ((rate_bytes[1] as u32) << 8) | (rate_bytes[2] as u32)); } - SAMPLE_RATES - .get(freq_index as usize) - .copied() - .ok_or(Error::UnsupportedSampleRateIndex(freq_index)) + /// Read `n` bits (n <= 32) MSB-first, returning `short` if the buffer runs dry. + fn read(&mut self, n: u8, short: Error) -> Result { + let mut value = 0u32; + for _ in 0..n { + if self.bits_left == 0 { + if !self.buf.has_remaining() { + return Err(short); + } + self.current = self.buf.get_u8(); + self.bits_left = 8; + } + self.bits_left -= 1; + value = (value << 1) | u32::from((self.current >> self.bits_left) & 1); + } + Ok(value) + } } /// Map an AAC `channel_config` (ISO 14496-3 Table 1.19) to its real channel count. @@ -233,11 +224,36 @@ mod tests { assert_eq!(cfg.channel_count, 2); } - // TODO: a round-trip test for the explicit-frequency (freq_index=0xF) form - // fails today because the parser reads `channel_config` from byte 1 even - // though ISO 14496-3 §1.6.2.1 puts it *after* the 24-bit explicit sample - // rate. The encoder follows the spec, the parser doesn't. Fixing requires - // a bit-level reader; deferred to a separate PR. + #[test] + fn round_trip_explicit_sample_rate() { + // A non-standard rate (no freq_index) forces the explicit 24-bit form, where + // channelConfiguration lands mid-byte after the rate. A byte-aligned parser + // misreads both fields; the bit reader round-trips them. + let cfg = Config { + profile: 2, + sample_rate: 44_056, // not in the standard table + channel_count: 2, + }; + let encoded = cfg.encode(); + assert_eq!(encoded.len(), 5, "explicit-rate config is 5 bytes"); + + let parsed = Config::parse(&mut encoded.as_ref()).unwrap(); + assert_eq!(parsed.profile, 2); + assert_eq!(parsed.sample_rate, 44_056); + assert_eq!(parsed.channel_count, 2); + } + + #[test] + fn parses_extended_object_type() { + // audioObjectType 31 escapes to a 6-bit extended type. Bytes encode + // AOT=31, ext=4 (-> object_type 36), freq_index=3 (48000), channel_config=2, + // which straddle byte boundaries: 11111 000100 0011 0010 + padding. + let buf: [u8; 3] = [0xF8, 0x86, 0x40]; + let cfg = Config::parse(&mut buf.as_slice()).unwrap(); + assert_eq!(cfg.profile, 36); + assert_eq!(cfg.sample_rate, 48_000); + assert_eq!(cfg.channel_count, 2); + } #[test] fn round_trip_5_1_channels() { diff --git a/rs/moq-mux/src/codec/h264/split.rs b/rs/moq-mux/src/codec/h264/split.rs index 0a69129ec..bd52d7698 100644 --- a/rs/moq-mux/src/codec/h264/split.rs +++ b/rs/moq-mux/src/codec/h264/split.rs @@ -138,6 +138,13 @@ impl Split { self.maybe_start_frame(pts)?; } Some(Avc3NalType::IdrSlice) => { + // first_mb_in_slice == 0 (ue(v), so the byte-after-header high bit is set) + // marks the first slice of a new picture: close any access unit still open. + // A bare IDR arriving right after a delta picture in the same chunk would + // otherwise fold both into one frame and mis-flag it a keyframe. + if nal.get(1).ok_or(Error::NalTooShort)? & 0x80 != 0 { + self.maybe_start_frame(pts)?; + } // Adopt this keyframe's inline set (dropping any the new GOP no longer // uses), or re-inject the retained set if the keyframe carried none. crate::codec::annexb::reconcile_keyframe_params( @@ -357,6 +364,39 @@ mod tests { ); } + /// A bare IDR arriving right after a delta picture in the same decode chunk must + /// open its own access unit, not fold into the delta's frame. Without closing the + /// open slice on the IDR's first slice, the two AUs merge and the result is + /// mis-flagged as a keyframe. + #[tokio::test(start_paused = true)] + async fn bare_idr_after_delta_splits() { + let sps: &[u8] = &[0x67, 0x42, 0xc0, 0x1f]; + let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; + let idr: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + // P-slice with first_mb_in_slice set (byte 1 high bit), opening a new AU. + let pslice: &[u8] = &[0x61, 0xe0, 0x12, 0x34]; + // A trailing AUD so the bare IDR is a *complete* NAL during decode. + let aud: &[u8] = &[0x09, 0x10]; + + let mut split = Split::new(); + // One chunk: keyframe, a delta picture, then a bare IDR (no inline params). + let frames = split.decode(&annexb(&[sps, pps, idr, pslice, idr, aud]), ts()).unwrap(); + + // The keyframe and the delta both completed; the second IDR's AU is still buffered. + assert_eq!(frames.len(), 2); + assert!(frames[0].keyframe, "first AU is the keyframe"); + assert!(!frames[1].keyframe, "the delta picture must not be flagged a keyframe"); + // The delta frame holds only its own slice, not a merged keyframe. + assert_eq!(frames[1].payload.as_ref(), annexb(&[pslice]).freeze().as_ref()); + + // Flushing closes the bare IDR as its own self-contained keyframe (params + // re-injected). The trailing AUD opens a fresh slice-less AU that is dropped. + let tail = split.flush(ts()).unwrap(); + assert_eq!(tail.len(), 1); + assert!(tail[0].keyframe); + assert_eq!(tail[0].payload.as_ref(), annexb(&[sps, pps, idr]).freeze().as_ref()); + } + /// A keyframe that presents a smaller parameter set than a prior one reinits /// the retained set: the dropped PPS must not be re-injected on later bare /// keyframes. diff --git a/rs/moq-mux/src/codec/h265/split.rs b/rs/moq-mux/src/codec/h265/split.rs index 185cc2748..f3097f2b5 100644 --- a/rs/moq-mux/src/codec/h265/split.rs +++ b/rs/moq-mux/src/codec/h265/split.rs @@ -151,6 +151,13 @@ impl Split { | NALUnitType::BlaWRadl | NALUnitType::BlaWLp | NALUnitType::CraNut => { + // first_slice_segment_in_pic_flag (bit 7 of the third byte, after the + // 2-byte header) marks the first slice of a new picture: close any access + // unit still open. A bare IDR arriving right after a delta picture in the + // same chunk would otherwise fold both into one frame and mis-flag it a keyframe. + if nal.get(2).ok_or(Error::NalTooShort)? & 0x80 != 0 { + self.maybe_start_frame(pts)?; + } // Adopt this keyframe's inline set (dropping any the new GOP no longer // uses), or re-inject the retained set if the keyframe carried none. crate::codec::annexb::reconcile_keyframe_params( @@ -343,6 +350,37 @@ mod tests { ); } + /// A bare IDR arriving right after a delta picture in the same decode chunk must + /// open its own access unit, not fold into the delta's frame. Without closing the + /// open slice on the IDR's first slice, the two AUs merge and the result is + /// mis-flagged as a keyframe. + #[tokio::test(start_paused = true)] + async fn bare_idr_after_delta_splits() { + // TrailR (type 1) with first_slice_segment_in_pic_flag set (byte 2 high bit). + const TRAIL: &[u8] = &[0x02, 0x01, 0x80, 0x33]; + const AUD: &[u8] = &[0x46, 0x01, 0x50]; // AudNut (type 35) + + let mut split = Split::new(); + // One chunk: keyframe, a delta picture, then a bare IDR (no inline params). + let frames = split + .decode(&annexb(&[VPS, SPS, PPS, IDR, TRAIL, IDR, AUD]), ts()) + .unwrap(); + + assert_eq!(frames.len(), 2); + assert!(frames[0].keyframe, "first AU is the keyframe"); + assert!(!frames[1].keyframe, "the delta picture must not be flagged a keyframe"); + assert_eq!(frames[1].payload.as_ref(), annexb(&[TRAIL]).freeze().as_ref()); + + // Flushing closes the bare IDR as its own self-contained keyframe. + let tail = split.flush(ts()).unwrap(); + assert_eq!(tail.len(), 1); + assert!(tail[0].keyframe); + assert_eq!( + tail[0].payload.as_ref(), + annexb(&[VPS, SPS, PPS, IDR]).freeze().as_ref() + ); + } + /// A keyframe that presents a smaller parameter set than a prior one reinits /// the retained set: the dropped PPS must not be re-injected on later bare /// keyframes. diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 73dd6bda6..16086489a 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -225,12 +225,21 @@ impl Consumer { // Still blocked on this group, don't skip it yet. Poll::Pending => break, Poll::Ready(Err(e)) => { - // The group was dropped/aborted -- typically it aged out of the relay - // cache (`Error::Old`) while we weren't reading it. Any sequences - // between it and the next buffered group were evicted alongside it, so - // jump straight to that group instead of stepping one-by-one and then - // blocking on a sequence gap of groups that will never arrive. - tracing::warn!(error = ?e, "current group dropped; skipping to next buffered group"); + // Tell a relay group eviction/abort (skip) from a payload decode error + // (propagate). The moq_net group's own terminal state is the source of + // truth: an evicted/aborted group reports the transport error from + // poll_finished, while a malformed payload leaves the group live or + // cleanly finished. A decode error is real and the caller must see it, + // not have the group silently dropped. + if !group.poll_aborted(waiter) { + return Poll::Ready(Err(e)); + } + // The group aged out of the relay cache (`Error::Old`) or was otherwise + // aborted. Any sequences between it and the next buffered group were + // evicted alongside it, so jump straight to that group instead of + // stepping one-by-one and then blocking on a sequence gap of groups + // that will never arrive. + tracing::warn!(error = ?e, "current group evicted; skipping to next buffered group"); self.pending.pop_front(); self.current = self.pending.front().map_or(self.current + 1, |g| g.sequence); } @@ -617,6 +626,15 @@ impl GroupBuffer { Poll::Pending } + + /// True if the group's moq_net stream was reset/aborted (evicted, `Old`, + /// cancelled, ...), as opposed to still live or cleanly finished. Lets the + /// consumer tell a transport eviction from a payload decode error: the former + /// surfaces as a terminal transport error from `poll_finished`, the latter + /// leaves the group readable or finished. + fn poll_aborted(&mut self, waiter: &kio::Waiter) -> bool { + matches!(self.group.poll_finished(waiter), Poll::Ready(Err(_))) + } } impl std::ops::Deref for GroupBuffer { @@ -1308,6 +1326,75 @@ mod tests { assert!(reached.is_ok(), "consumer hung on a missing sequence on a live track"); } + // ---- Decode errors ---- + + /// A container that decodes each frame's payload as an 8-byte LE microsecond + /// timestamp, but treats a `FAIL` payload as a malformed frame. Lets a test put a + /// decodable frame first (so startup selects the group) and a decode failure after. + struct FailingDecode; + + impl ContainerTrait for FailingDecode { + type Error = crate::Error; + + fn write(&self, group: &mut moq_net::GroupProducer, frames: &[Frame]) -> Result<(), Self::Error> { + for frame in frames { + group.write_frame(frame.payload.clone())?; + } + Ok(()) + } + + fn poll_read( + &self, + group: &mut moq_net::GroupConsumer, + waiter: &kio::Waiter, + ) -> Poll>, Self::Error>> { + use bytes::Buf; + + let Some(mut data) = ready!(group.poll_read_frame(waiter)?) else { + return Poll::Ready(Ok(None)); + }; + if data.as_ref() == b"FAIL" { + return Poll::Ready(Err(crate::Error::UnknownFormat("malformed payload".into()))); + } + Poll::Ready(Ok(Some(vec![Frame { + timestamp: ts(data.get_u64_le()), + payload: Bytes::new(), + keyframe: false, + duration: None, + }]))) + } + } + + /// A decode error on a cleanly-finished group must propagate to the caller, not be + /// mistaken for a relay eviction and silently skipped. Eviction-skip only fires when + /// the group's stream was actually aborted. + #[tokio::test] + async fn decode_error_propagates() { + tokio::time::pause(); + let mut track = track_producer("test"); + let consumer_track = track.consume(); + let mut consumer = Consumer::new(consumer_track, FailingDecode); + + // A decodable frame first (so startup selects the group), then a malformed one. + let mut group = track.create_group(moq_net::Group { sequence: 0 }).unwrap(); + group.write_frame(Bytes::from(0u64.to_le_bytes().to_vec())).unwrap(); + group.write_frame(Bytes::from_static(b"FAIL")).unwrap(); + group.finish().unwrap(); + track.finish().unwrap(); + + // The first frame decodes; the malformed second frame must surface as an error. + let first = consumer.read().await; + assert!(matches!(first, Ok(Some(_))), "first frame should decode, got {first:?}"); + + let second = tokio::time::timeout(Duration::from_millis(200), consumer.read()) + .await + .expect("consumer hung on a decode error"); + assert!( + matches!(second, Err(crate::Error::UnknownFormat(_))), + "decode error must propagate, got {second:?}" + ); + } + // ---- Frame Decoding ---- #[tokio::test] diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index 6cbbeefde..6795b5fd8 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -247,3 +247,45 @@ async fn test_msf_catalog_roundtrip() { assert_eq!(audio.channel_count, 2); assert!(matches!(audio.container, Container::Cmaf { .. })); } + +// ---- Sample-duration handling in decode() ---- + +fn sample(timestamp_us: u64, keyframe: bool, duration_us: Option) -> crate::container::Frame { + crate::container::Frame { + timestamp: crate::container::Timestamp::from_micros(timestamp_us).unwrap(), + payload: bytes::Bytes::from_static(&[0xDE, 0xAD]), + keyframe, + duration: duration_us.map(|d| crate::container::Timestamp::from_micros(d).unwrap()), + } +} + +/// A multi-sample fragment whose non-final sample carries no duration can't have its +/// DTS reconstructed, so decode rejects it rather than collapsing the timestamps. +#[test] +fn decode_rejects_durationless_multisample() { + let frames = vec![sample(0, true, None), sample(33_000, false, None)]; + let frag = super::encode_fragment(1, 1_000_000, 0, &frames).unwrap(); + let err = super::decode(frag, 1_000_000).unwrap_err(); + assert!(matches!(err, super::Error::MissingSampleDuration), "got {err:?}"); +} + +/// A single-sample fragment needs no duration (nothing follows it), so it still decodes. +#[test] +fn decode_single_sample_no_duration_ok() { + let frag = super::encode_fragment(1, 1_000_000, 0, &[sample(0, true, None)]).unwrap(); + let out = super::decode(frag, 1_000_000).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].timestamp.as_micros(), 0); +} + +/// With the durations the producer now backfills, every sample's DTS round-trips +/// through a multi-sample fragment. +#[test] +fn decode_multisample_with_durations_roundtrips() { + let frames = vec![sample(0, true, Some(33_000)), sample(33_000, false, Some(33_000))]; + let frag = super::encode_fragment(1, 1_000_000, 0, &frames).unwrap(); + let out = super::decode(frag, 1_000_000).unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0].timestamp.as_micros(), 0); + assert_eq!(out[1].timestamp.as_micros(), 33_000); +} diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 4b43ea8ba..000a42e1c 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -126,6 +126,9 @@ pub enum Error { #[error("audio codec {0} needs a description (AudioSpecificConfig) to synthesize a CMAF init")] MissingAudioDescription(String), + + #[error("multi-sample fragment has a non-final sample with no duration; DTS is unrecoverable")] + MissingSampleDuration, } impl From for Error { @@ -225,9 +228,15 @@ pub(crate) fn decode(data: Bytes, timescale: u64) -> Result> { let default_size = traf.tfhd.default_sample_size; let default_duration = traf.tfhd.default_sample_duration; + // DTS is reconstructed by accumulating each sample's duration. A non-final sample + // with no resolvable duration would leave every following sample stuck at the same + // DTS, silently collapsing their timestamps, so reject that fragment instead. + let total_samples: usize = traf.trun.iter().map(|t| t.entries.len()).sum(); + let mut frames = Vec::new(); let mut offset = 0usize; let mut dts = base_dts; + let mut sample_index = 0usize; for trun in &traf.trun { for entry in &trun.entries { @@ -254,6 +263,14 @@ pub(crate) fn decode(data: Bytes, timescale: u64) -> Result> { // Carry the sample-duration through at the track's scale when present, so // the jitter buffer can use it and an exporter can write it back. let sample_duration = entry.duration.or(default_duration); + + // The last sample needs no duration (nothing follows it to time), but any + // earlier sample without one makes the rest of the fragment's DTS ambiguous. + let is_last = sample_index + 1 == total_samples; + if sample_duration.is_none() && !is_last { + return Err(Error::MissingSampleDuration); + } + let duration = sample_duration .map(|d| Timestamp::from_scale(d as u64, timescale)) .transpose()?; @@ -267,6 +284,7 @@ pub(crate) fn decode(data: Bytes, timescale: u64) -> Result> { offset = end; dts += sample_duration.unwrap_or(0) as u64; + sample_index += 1; } } diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index fb9583c6e..c0ac31c3c 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -150,22 +150,30 @@ impl Producer { /// Flush any buffered frames into the current group without closing it. /// - /// `next`, when given, is the timestamp of the frame that rolled the group over - /// (the next keyframe). The buffer's last frame is the only sample whose successor - /// wasn't visible when it arrived, so we backfill its duration from `next` here. - /// This adds no latency: that frame is already in hand. Containers that don't use - /// per-frame durations (Legacy, LOC) ignore it. + /// Backfills the per-sample duration the source didn't provide. A CMAF fragment + /// reconstructs each sample's DTS by accumulating durations, so every non-final + /// sample packed into one fragment needs one or the decoder collapses their + /// timestamps. Frames are in decode order, so a sample's duration is the gap to the + /// next buffered sample; the final sample borrows `next` (the timestamp of the + /// keyframe that rolled the group over), which is already in hand so this adds no + /// latency. Frames that already carry a duration (e.g. fMP4 passthrough) keep it, + /// and a backwards gap (a B-frame whose successor presents earlier) is left unset. + /// Containers that don't use per-frame durations (Legacy, LOC) ignore the field. fn flush(&mut self, next: Option) -> Result<(), C::Error> { if self.buffer.is_empty() { return Ok(()); } - if let Some(next) = next - && let Some(last) = self.buffer.last_mut() - && last.duration.is_none() - && let Ok(duration) = next.checked_sub(last.timestamp) - { - last.duration = Some(duration); + for i in 0..self.buffer.len() { + if self.buffer[i].duration.is_some() { + continue; + } + let boundary = self.buffer.get(i + 1).map(|f| f.timestamp).or(next); + if let Some(boundary) = boundary + && let Ok(duration) = boundary.checked_sub(self.buffer[i].timestamp) + { + self.buffer[i].duration = Some(duration); + } } let group = match &mut self.group { @@ -342,10 +350,11 @@ mod tests { } } - /// The keyframe that rolls a group over backfills the duration of the previous - /// group's last frame, without buffering an extra frame. + /// Every sample batched into one fragment gets a backfilled duration (from the next + /// buffered sample, or the rolling keyframe for the last), so a CMAF decoder can + /// reconstruct each DTS. No extra frame is buffered to learn the final boundary. #[tokio::test] - async fn keyframe_backfills_last_frame_duration() { + async fn keyframe_backfills_batched_durations() { let track = track_producer("test"); let recording = Recording::default(); let mut producer = Producer::new(track, recording.clone()).with_latency(std::time::Duration::from_secs(10)); @@ -358,9 +367,9 @@ mod tests { let writes = recording.0.borrow(); let group0 = &writes[0]; assert_eq!(group0.len(), 2); - // Last frame's duration backfilled from the next keyframe: 66ms - 33ms. + // The first sample's duration is the gap to the next buffered sample: 33ms - 0. + assert_eq!(group0[0].duration, Some(Timestamp::from_micros(33_000).unwrap())); + // The last sample's duration is backfilled from the next keyframe: 66ms - 33ms. assert_eq!(group0[1].duration, Some(Timestamp::from_micros(33_000).unwrap())); - // The earlier frame keeps None; only the trailing sample needs the boundary. - assert_eq!(group0[0].duration, None); } } From 3ce0331de8d021988a5d54fcbbc62abf6ed13f1b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Jun 2026 14:08:30 -0700 Subject: [PATCH 19/34] [codex] Backport moq-hls to main (#1924) Co-authored-by: Claude Opus 4.8 --- Cargo.lock | 94 +++- Cargo.toml | 2 + rs/moq-hls/Cargo.toml | 64 +++ rs/moq-hls/bin/moq-hls.rs | 213 +++++++++ rs/moq-hls/src/error.rs | 61 +++ rs/moq-hls/src/export/master.rs | 128 ++++++ rs/moq-hls/src/export/mod.rs | 198 +++++++++ rs/moq-hls/src/export/playlist.rs | 192 ++++++++ rs/moq-hls/src/export/rendition.rs | 179 ++++++++ rs/moq-hls/src/export/store.rs | 341 ++++++++++++++ rs/moq-hls/src/import.rs | 662 ++++++++++++++++++++++++++++ rs/moq-hls/src/lib.rs | 24 + rs/moq-hls/src/server/mod.rs | 74 ++++ rs/moq-hls/src/server/routes.rs | 172 ++++++++ rs/moq-mux/src/container/hls/mod.rs | 53 --- 15 files changed, 2390 insertions(+), 67 deletions(-) create mode 100644 rs/moq-hls/Cargo.toml create mode 100644 rs/moq-hls/bin/moq-hls.rs create mode 100644 rs/moq-hls/src/error.rs create mode 100644 rs/moq-hls/src/export/master.rs create mode 100644 rs/moq-hls/src/export/mod.rs create mode 100644 rs/moq-hls/src/export/playlist.rs create mode 100644 rs/moq-hls/src/export/rendition.rs create mode 100644 rs/moq-hls/src/export/store.rs create mode 100644 rs/moq-hls/src/import.rs create mode 100644 rs/moq-hls/src/lib.rs create mode 100644 rs/moq-hls/src/server/mod.rs create mode 100644 rs/moq-hls/src/server/routes.rs delete mode 100644 rs/moq-mux/src/container/hls/mod.rs diff --git a/Cargo.lock b/Cargo.lock index afc630618..e990a342e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,7 +155,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -166,7 +166,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -299,6 +299,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -1067,6 +1079,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1533,7 +1562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -1978,7 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3546,7 +3575,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3885,6 +3914,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "m3u8-rs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03cd3335fb5f2447755d45cda9c70f76013626a9db44374973791b0926a86c3" +dependencies = [ + "chrono", + "nom 7.1.3", +] + [[package]] name = "mac-addr" version = "0.3.0" @@ -4118,6 +4157,32 @@ dependencies = [ "url", ] +[[package]] +name = "moq-hls" +version = "0.0.1" +dependencies = [ + "anyhow", + "axum", + "axum-server", + "bytes", + "clap", + "hang", + "humantime", + "kio", + "m3u8-rs", + "moq-mux", + "moq-native", + "moq-net", + "reqwest 0.12.28", + "rustls", + "sd-notify", + "thiserror 2.0.18", + "tokio", + "tower-http", + "tracing", + "url", +] + [[package]] name = "moq-json" version = "0.1.0" @@ -4799,7 +4864,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6476,7 +6541,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6535,7 +6600,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6556,7 +6621,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6786,7 +6851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7225,7 +7290,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7555,7 +7620,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7564,7 +7629,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8053,6 +8118,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", @@ -8880,7 +8946,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cf248294c..595615262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "rs/moq-ffi", "rs/moq-flate", "rs/moq-gst", + "rs/moq-hls", "rs/moq-json", "rs/moq-loc", "rs/moq-msf", @@ -35,6 +36,7 @@ default-members = [ # "rs/moq-ffi", # requires Python/maturin # "rs/moq-gst", # requires GStreamer "rs/moq-flate", + "rs/moq-hls", "rs/moq-json", "rs/moq-loc", "rs/moq-msf", diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml new file mode 100644 index 000000000..ceb464835 --- /dev/null +++ b/rs/moq-hls/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "moq-hls" +description = "HLS / LL-HLS gateway for Media over QUIC" +authors = ["Luke Curley "] +repository = "https://github.com/moq-dev/moq" +license = "MIT OR Apache-2.0" + +version = "0.0.1" +edition = "2024" +rust-version.workspace = true + +keywords = ["hls", "ll-hls", "moq", "media", "cmaf"] +categories = ["multimedia", "network-programming", "web-programming"] + +[lib] +doctest = false + +[[bin]] +name = "moq-hls" +path = "bin/moq-hls.rs" +doc = false +# The binary (and the HTTP export server) need the `server` feature; the library +# can be depended on with `default-features = false` for import only (e.g. moq-cli). +required-features = ["server"] + +[features] +default = ["server", "iroh", "noq", "websocket"] +# HTTP export server + the moq-hls binary. Pulls in axum / moq-native / clap. +server = ["dep:axum", "dep:axum-server", "dep:clap", "dep:humantime", "dep:moq-native", "dep:rustls", "dep:sd-notify", "dep:tower-http"] +iroh = ["server", "moq-native/iroh"] +noq = ["server", "moq-native/noq"] +quinn = ["server", "moq-native/quinn"] +quiche = ["server", "moq-native/quiche"] +websocket = ["server", "moq-native/websocket"] + +[dependencies] +# Always required (import + export library). +anyhow = { version = "1", features = ["backtrace"] } + +# Only needed by the HTTP export server / binary (gated by `server`). +axum = { version = "0.8", features = ["tokio"], optional = true } +axum-server = { version = "0.8", features = ["tls-rustls"], optional = true } +bytes = "1" +clap = { version = "4", features = ["derive"], optional = true } +hang = { workspace = true } +humantime = { version = "2.3", optional = true } +kio = { workspace = true } +m3u8-rs = "6" +moq-mux = { workspace = true } +moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"], optional = true } +moq-net = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] } +rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false, optional = true } +thiserror = "2" +tokio = { workspace = true, features = ["full"] } +tower-http = { version = "0.6", features = ["cors"], optional = true } +tracing = "0.1" +url = "2" + +[target.'cfg(unix)'.dependencies] +sd-notify = { version = "0.5", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/rs/moq-hls/bin/moq-hls.rs b/rs/moq-hls/bin/moq-hls.rs new file mode 100644 index 000000000..068a37d14 --- /dev/null +++ b/rs/moq-hls/bin/moq-hls.rs @@ -0,0 +1,213 @@ +//! `moq-hls` binary. +//! +//! Two subcommands under shared relay/client globals: +//! +//! - `export` -- subscribe to MoQ broadcasts and serve HLS + LL-HLS over HTTP +//! (an HTTP *server* that *subscribes*; the WHEP-server analogue in `moq-rtc`). +//! - `import` -- pull a remote HLS playlist and publish it into MoQ (an HTTP +//! *client* that *publishes*; the WHEP-client analogue in `moq-rtc`). +//! +//! HLS isn't a symmetric push/pull protocol like WHIP/WHEP, so these are +//! explicit subcommands rather than a `server`/`client` x `publish`/`subscribe` +//! matrix. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use axum::Router; +use clap::{Parser, Subcommand}; +use moq_hls::Server; +use tower_http::cors::{Any, CorsLayer}; +use url::Url; + +#[derive(Parser, Clone)] +#[command(version)] +struct Cli { + #[command(flatten)] + log: moq_native::Log, + + /// MoQ client configuration for dialing the upstream relay. + #[command(flatten)] + moq_client: moq_native::ClientConfig, + + /// URL of the upstream MoQ relay to publish into (import) or read from (export). + #[arg(long, env = "MOQ_HLS_RELAY")] + relay: Url, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Clone)] +enum Command { + /// Serve HLS / LL-HLS over HTTP from MoQ broadcasts (path-based, multi-broadcast). + Export { + /// HTTP listener for the HLS endpoints. + #[arg(long, env = "MOQ_HLS_LISTEN", default_value = "[::]:8089")] + listen: SocketAddr, + + /// TLS certificates, keys, self-signed generation, and optional mTLS roots. + /// Serve HTTPS by setting `--tls-cert`/`--tls-key` or `--tls-generate`. + /// Most players require HTTPS. + #[command(flatten)] + tls: moq_native::tls::Server, + + /// LL-HLS part target duration (also caps the exporter's fragment duration). + #[arg(long, env = "MOQ_HLS_PART_TARGET", default_value = "500ms", value_parser = humantime::parse_duration)] + part_target: Duration, + + /// Minimum duration of media kept in each rendition's sliding window. + #[arg(long, env = "MOQ_HLS_WINDOW", default_value = "16s", value_parser = humantime::parse_duration)] + window: Duration, + }, + /// Pull a remote HLS master/media playlist and publish it into MoQ. + Import { + /// Broadcast name to publish on the relay. + #[arg(long, alias = "name", env = "MOQ_HLS_BROADCAST")] + broadcast: String, + + /// Remote HLS playlist URL (http/https) or local file path. + #[arg(long, env = "MOQ_HLS_PLAYLIST")] + playlist: String, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("failed to install default crypto provider"); + + let Cli { + log, + moq_client, + relay, + command, + } = Cli::parse(); + log.init()?; + + let client = moq_client.init().context("failed to init moq client")?; + + match command { + Command::Export { + listen, + tls, + part_target, + window, + } => { + let subscriber = moq_net::Origin::random().produce(); + let subscriber_consumer = subscriber.consume(); + let reconnect = client.with_consume(subscriber).reconnect(relay.clone()); + + let config = moq_hls::export::Config { + part_target, + window, + ..Default::default() + }; + let server = Server::new(subscriber_consumer, config); + let app = server + .router() + .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any)); + + // Serve HTTPS only when a cert/key pair or self-signed generation is configured. + let tls = if tls.cert.is_empty() && tls.generate.is_empty() { + None + } else { + let alpn = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Some(tls.server_config(alpn).context("failed to build TLS config")?) + }; + + // Bind before signaling readiness so a port conflict surfaces as a startup + // failure instead of systemd briefly seeing a dead instance as healthy. + let listener = std::net::TcpListener::bind(listen).context("failed to bind HLS listener")?; + + tracing::info!(%relay, %listen, "moq-hls serving HLS"); + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + tokio::select! { + res = serve(listener, app, tls) => res, + res = reconnect.closed() => res.map_err(Into::into), + _ = shutdown_signal() => Ok(()), + } + } + Command::Import { broadcast, playlist } => { + let publisher = moq_net::Origin::random().produce(); + let reconnect = client.with_publish(publisher.consume()).reconnect(relay.clone()); + + let mut producer = moq_net::Broadcast::new().produce(); + let consumer = producer.consume(); + anyhow::ensure!( + publisher.publish_broadcast(&broadcast, consumer), + "failed to publish broadcast" + ); + + let catalog = moq_mux::catalog::Producer::new(&mut producer).context("failed to create catalog")?; + let mut importer = moq_hls::import::Import::new(producer, catalog, moq_hls::import::Config::new(playlist))?; + + tracing::info!(%relay, %broadcast, "moq-hls importing HLS"); + + tokio::select! { + res = async { + // Signal readiness only once the source is validated and primed, not + // before, so a bad playlist URL fails startup instead of reporting healthy. + importer.init().await?; + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + importer.run().await + } => res.map_err(Into::into), + res = reconnect.closed() => res.map_err(Into::into), + _ = shutdown_signal() => Ok(()), + } + } + } +} + +/// Resolve when the process is asked to shut down: Ctrl-C, or SIGTERM on Unix +/// (which is how systemd and most supervisors stop a service). +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + + let mut term = match signal(SignalKind::terminate()) { + Ok(term) => term, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + } + + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + +async fn serve( + listener: std::net::TcpListener, + app: Router, + tls: Option>, +) -> anyhow::Result<()> { + let service = app.into_make_service(); + match tls { + Some(config) => { + let config = axum_server::tls_rustls::RustlsConfig::from_config(config); + axum_server::from_tcp_rustls(listener, config)?.serve(service).await?; + } + None => { + axum_server::from_tcp(listener)?.serve(service).await?; + } + } + Ok(()) +} diff --git a/rs/moq-hls/src/error.rs b/rs/moq-hls/src/error.rs new file mode 100644 index 000000000..aaab410eb --- /dev/null +++ b/rs/moq-hls/src/error.rs @@ -0,0 +1,61 @@ +//! Errors for the HLS / LL-HLS gateway. + +/// Errors produced by the HLS <-> MoQ gateway (import and export). +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// Error from the underlying moq-net transport. + #[error("moq: {0}")] + Moq(#[from] moq_net::Error), + + /// Error from the moq-mux CMAF import/export layer. + #[error("mux: {0}")] + Mux(#[from] moq_mux::Error), + + /// The playlist argument looked like an HTTP(S) URL but failed to parse. + #[error("invalid playlist URL")] + InvalidPlaylistUrl, + + /// The playlist argument was a local path that could not be made into a `file://` URL. + #[error("invalid file path")] + InvalidFilePath, + + /// A `file://` URL could not be turned back into a filesystem path. + #[error("invalid file URL")] + InvalidFileUrl, + + /// The fetched media playlist could not be parsed. + #[error("failed to parse media playlist: {0}")] + ParsePlaylist(String), + + /// The master playlist contained no variant this gateway can import. + #[error("no usable variants found in master playlist")] + NoVariants, + + /// A media playlist had no `EXT-X-MAP`, so there is no CMAF init segment. + #[error("playlist missing EXT-X-MAP")] + MissingMap, + + /// A media segment had an empty URI. + #[error("encountered segment with empty URI")] + EmptySegmentUri, + + /// A playlist or segment URI could not be resolved against its base. + #[error(transparent)] + UrlParse(#[from] url::ParseError), + + /// HTTP error while fetching a playlist or segment. + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + + /// I/O error while reading a local playlist or segment. + #[error(transparent)] + Io(#[from] std::io::Error), + + /// Catch-all for gateway logic that reports via `anyhow`. + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +/// Convenience alias for results from the HLS gateway. +pub type Result = std::result::Result; diff --git a/rs/moq-hls/src/export/master.rs b/rs/moq-hls/src/export/master.rs new file mode 100644 index 000000000..005ee05e6 --- /dev/null +++ b/rs/moq-hls/src/export/master.rs @@ -0,0 +1,128 @@ +//! Hand-written HLS multivariant (master) playlist generation. +//! +//! URIs are relative to the master playlist (`//master.m3u8`), so a +//! rendition's `/media.m3u8` resolves under the broadcast directory. + +use std::fmt::Write; + +const VERSION: u32 = 9; +const AUDIO_GROUP: &str = "aud"; + +/// A video rendition entry for the master playlist. +pub struct VideoVariant { + /// Rendition name (also its `/media.m3u8` path component). + pub name: String, + /// `BANDWIDTH` attribute, in bits per second. + pub bandwidth: u64, + /// Coded width for the `RESOLUTION` attribute, if known. + pub width: Option, + /// Coded height for the `RESOLUTION` attribute, if known. + pub height: Option, + /// RFC 6381 codec string (e.g. `avc1.42c01f`). + pub codec: String, +} + +/// An audio rendition entry for the master playlist. +pub struct AudioVariant { + /// Rendition name (also its `/media.m3u8` path component). + pub name: String, + /// `BANDWIDTH` attribute, in bits per second. + pub bandwidth: u64, + /// RFC 6381 codec string (e.g. `mp4a.40.2`). + pub codec: String, +} + +/// Render the multivariant playlist. The first audio rendition is marked default. +pub fn render_master(video: &[VideoVariant], audio: &[AudioVariant]) -> String { + let mut out = String::new(); + let _ = writeln!(out, "#EXTM3U"); + let _ = writeln!(out, "#EXT-X-VERSION:{VERSION}"); + + let has_audio = !audio.is_empty(); + for (index, variant) in audio.iter().enumerate() { + let default = if index == 0 { "YES" } else { "NO" }; + let _ = writeln!( + out, + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"{AUDIO_GROUP}\",NAME=\"{}\",DEFAULT={default},AUTOSELECT=YES,URI=\"{}/media.m3u8\"", + variant.name, variant.name + ); + } + + // One audio codec is enough for the combined CODECS attribute. + let audio_codec = audio.first().map(|a| a.codec.as_str()); + + for variant in video { + let codecs = match audio_codec { + Some(audio) => format!("{},{}", variant.codec, audio), + None => variant.codec.clone(), + }; + + let mut line = format!("#EXT-X-STREAM-INF:BANDWIDTH={}", variant.bandwidth); + if let (Some(w), Some(h)) = (variant.width, variant.height) { + let _ = write!(line, ",RESOLUTION={w}x{h}"); + } + let _ = write!(line, ",CODECS=\"{codecs}\""); + if has_audio { + let _ = write!(line, ",AUDIO=\"{AUDIO_GROUP}\""); + } + let _ = writeln!(out, "{line}"); + let _ = writeln!(out, "{}/media.m3u8", variant.name); + } + + // Audio-only broadcast: still expose a playable variant per audio rendition. + if video.is_empty() { + for variant in audio { + let _ = writeln!( + out, + "#EXT-X-STREAM-INF:BANDWIDTH={},CODECS=\"{}\"", + variant.bandwidth, variant.codec + ); + let _ = writeln!(out, "{}/media.m3u8", variant.name); + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_video_and_audio() { + let video = vec![VideoVariant { + name: "video".into(), + bandwidth: 2_500_000, + width: Some(1280), + height: Some(720), + codec: "avc1.42c01f".into(), + }]; + let audio = vec![AudioVariant { + name: "audio".into(), + bandwidth: 128_000, + codec: "mp4a.40.2".into(), + }]; + + let out = render_master(&video, &audio); + assert!(out.starts_with("#EXTM3U\n#EXT-X-VERSION:9\n")); + assert!(out.contains( + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"audio\",DEFAULT=YES,AUTOSELECT=YES,URI=\"audio/media.m3u8\"\n" + )); + assert!(out.contains( + "#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,CODECS=\"avc1.42c01f,mp4a.40.2\",AUDIO=\"aud\"\n" + )); + assert!(out.contains("\nvideo/media.m3u8\n")); + } + + #[test] + fn audio_only_is_playable() { + let audio = vec![AudioVariant { + name: "audio".into(), + bandwidth: 128_000, + codec: "opus".into(), + }]; + let out = render_master(&[], &audio); + assert!(out.contains("#EXT-X-STREAM-INF:BANDWIDTH=128000,CODECS=\"opus\"\n")); + assert!(out.contains("\naudio/media.m3u8\n")); + } +} diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs new file mode 100644 index 000000000..1eae0c1c0 --- /dev/null +++ b/rs/moq-hls/src/export/mod.rs @@ -0,0 +1,198 @@ +//! Export: subscribe to a MoQ broadcast and turn it into HLS / LL-HLS. +//! +//! A [`Broadcaster`] watches one broadcast's catalog and, per rendition, runs a +//! [`moq_mux::container::fmp4::Export`] narrowed to that single track (via +//! [`moq_mux::catalog::Filter`]) feeding a [`store::SegmentStore`]. The HTTP +//! [`server`](crate::server) reads the stores to answer playlist and segment +//! requests. + +mod master; +mod playlist; +mod rendition; +pub mod store; + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use moq_mux::catalog::hang::Catalog; +use moq_mux::catalog::{self, CatalogFormat, Stream}; +use tokio::sync::watch; + +pub use playlist::render_media; +pub use rendition::{Kind, Rendition}; + +/// How long to wait before retrying the initial catalog subscription. +const CATALOG_RETRY: Duration = Duration::from_millis(250); + +/// Export tuning shared across renditions. +#[derive(Clone, Debug)] +pub struct Config { + /// LL-HLS part target duration (also the exporter's fragment cap). + pub part_target: Duration, + /// Minimum duration of media retained in each rendition's sliding window. + /// Older segments are evicted once the remaining ones still cover this span. + pub window: Duration, + /// Exporter latency budget. Generous so live GOPs aren't skipped; see the + /// group-skip note in the crate plan. + pub latency: Duration, + /// Target segment duration for audio renditions (video rolls on GOPs). + pub audio_segment_target: Duration, +} + +impl Default for Config { + fn default() -> Self { + Self { + part_target: Duration::from_millis(500), + window: Duration::from_secs(16), + latency: Duration::from_secs(10), + audio_segment_target: Duration::from_secs(2), + } + } +} + +/// All renditions of one broadcast, kept in sync with its catalog. +pub struct Broadcaster { + renditions: Mutex>>, + /// Current rendition count, bumped on every catalog sync so handlers can wait + /// for the catalog to populate before rendering a playlist. + ready: watch::Sender, + /// Pause flag shared with every rendition pump. While true the pumps stop + /// reading; renditions discovered later inherit the current value (they + /// `subscribe()` to this sender). + paused: watch::Sender, +} + +impl Broadcaster { + /// Subscribe to `broadcast` and start tracking its renditions. + pub fn new(broadcast: moq_net::BroadcastConsumer, config: Config) -> Arc { + let (ready, _) = watch::channel(0); + let (paused, _) = watch::channel(false); + let broadcaster = Arc::new(Self { + renditions: Mutex::new(BTreeMap::new()), + ready, + paused, + }); + tokio::spawn(watch_catalog(broadcast, config, broadcaster.clone())); + broadcaster + } + + /// Pause or resume pulling media from the broadcast. + /// + /// While paused, every rendition's pump stops reading its track, so the relay + /// stops sending and the live media produced during the pause is dropped from the + /// recording (not buffered, and the publisher isn't kept ingesting). Resuming + /// continues the SAME playlists from the next group still in the relay cache (the + /// evicted span is skipped, then it reads forward -- it does NOT jump to live), + /// marking the first post-resume segment `#EXT-X-DISCONTINUITY`. CMAF sequence + /// numbers and the init segment persist, so it's one continuous recording with a + /// gap, not a restart. Idempotent. + pub fn set_paused(&self, paused: bool) { + let _ = self.paused.send(paused); + } + + /// Whether the export is currently paused. + pub fn is_paused(&self) -> bool { + *self.paused.borrow() + } + + /// Look up a rendition by name. + pub fn rendition(&self, name: &str) -> Option> { + self.renditions.lock().unwrap().get(name).cloned() + } + + /// Wait until at least one rendition has been discovered, or `timeout` elapses. + pub async fn wait_ready(&self, timeout: Duration) { + let mut rx = self.ready.subscribe(); + if *rx.borrow() > 0 { + return; + } + let _ = tokio::time::timeout(timeout, async { + while rx.changed().await.is_ok() { + if *rx.borrow() > 0 { + break; + } + } + }) + .await; + } + + /// Render the multivariant (master) playlist from the current renditions. + pub fn master_playlist(&self) -> String { + let renditions = self.renditions.lock().unwrap(); + let mut video = Vec::new(); + let mut audio = Vec::new(); + for rendition in renditions.values() { + match rendition.kind { + Kind::Video => video.push(master::VideoVariant { + name: rendition.name.clone(), + bandwidth: rendition.bandwidth, + width: rendition.width, + height: rendition.height, + codec: rendition.codec.clone(), + }), + Kind::Audio => audio.push(master::AudioVariant { + name: rendition.name.clone(), + bandwidth: rendition.bandwidth, + codec: rendition.codec.clone(), + }), + } + } + master::render_master(&video, &audio) + } + + /// Add renditions newly present in `catalog`. Renditions are not removed when + /// they disappear; their stores simply go stale (rare for a live broadcast). + fn sync(&self, broadcast: &moq_net::BroadcastConsumer, config: &Config, catalog: &Catalog) { + let mut renditions = self.renditions.lock().unwrap(); + for (name, video) in &catalog.video.renditions { + renditions.entry(name.clone()).or_insert_with(|| { + Arc::new(Rendition::video( + name.clone(), + video, + broadcast.clone(), + config, + self.paused.subscribe(), + )) + }); + } + for (name, audio) in &catalog.audio.renditions { + renditions.entry(name.clone()).or_insert_with(|| { + Arc::new(Rendition::audio( + name.clone(), + audio, + broadcast.clone(), + config, + self.paused.subscribe(), + )) + }); + } + let _ = self.ready.send(renditions.len()); + } +} + +async fn watch_catalog(broadcast: moq_net::BroadcastConsumer, config: Config, broadcaster: Arc) { + let mut consumer = loop { + match catalog::Consumer::<()>::new(&broadcast, CatalogFormat::Hang) { + Ok(consumer) => break consumer, + Err(err) => { + tracing::warn!(%err, "failed to subscribe to broadcast catalog, retrying"); + tokio::select! { + _ = tokio::time::sleep(CATALOG_RETRY) => {} + _ = kio::wait(|waiter| broadcast.poll_closed(waiter)) => return, + } + } + } + }; + + loop { + match kio::wait(|waiter| consumer.poll_next(waiter)).await { + Ok(Some(catalog)) => broadcaster.sync(&broadcast, &config, &catalog), + Ok(None) => break, + Err(err) => { + tracing::warn!(%err, "broadcast catalog stream ended with error"); + break; + } + } + } +} diff --git a/rs/moq-hls/src/export/playlist.rs b/rs/moq-hls/src/export/playlist.rs new file mode 100644 index 000000000..d42347320 --- /dev/null +++ b/rs/moq-hls/src/export/playlist.rs @@ -0,0 +1,192 @@ +//! Hand-written HLS / LL-HLS media playlist generation. +//! +//! `m3u8-rs` can parse classic playlists but cannot emit the LL-HLS tags +//! (`EXT-X-PART`, `EXT-X-PART-INF`, `EXT-X-SERVER-CONTROL`, +//! `EXT-X-PRELOAD-HINT`), so the export playlists are written by hand. URIs are +//! relative to the media playlist (`///media.m3u8`), so +//! they resolve against the rendition directory. + +use std::fmt::Write; + +use super::store::Snapshot; + +/// LL-HLS compatibility version: required for `EXT-X-PART` and friends. +const VERSION: u32 = 9; + +/// Render a media playlist for one rendition from a [`Snapshot`]. +pub fn render_media(snapshot: &Snapshot) -> String { + // TARGETDURATION must be >= the longest *complete* segment (rounded up), and + // at least the part target so a part-only edge still produces a sane value. + let max_segment = snapshot + .segments + .iter() + .filter(|s| s.complete) + .map(|s| s.duration) + .fold(0.0_f64, f64::max) + .max(snapshot.part_target); + let target_duration = max_segment.ceil().max(1.0) as u64; + + // PART-HOLD-BACK must be at least 3x the part target (HLS spec). + let part_hold_back = snapshot.part_target * 3.0; + + let mut out = String::new(); + let _ = writeln!(out, "#EXTM3U"); + let _ = writeln!(out, "#EXT-X-VERSION:{VERSION}"); + let _ = writeln!(out, "#EXT-X-TARGETDURATION:{target_duration}"); + let _ = writeln!( + out, + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={part_hold_back:.3}" + ); + let _ = writeln!(out, "#EXT-X-PART-INF:PART-TARGET={:.3}", snapshot.part_target); + let _ = writeln!(out, "#EXT-X-MEDIA-SEQUENCE:{}", snapshot.media_sequence); + let _ = writeln!(out, "#EXT-X-MAP:URI=\"init.mp4\""); + + for segment in &snapshot.segments { + if segment.discontinuity { + let _ = writeln!(out, "#EXT-X-DISCONTINUITY"); + } + for (index, part) in segment.parts.iter().enumerate() { + let independent = if part.independent { ",INDEPENDENT=YES" } else { "" }; + let _ = writeln!( + out, + "#EXT-X-PART:DURATION={:.5},URI=\"part/{}/{}.m4s\"{}", + part.duration, segment.sequence, index, independent + ); + } + if segment.complete { + let _ = writeln!(out, "#EXTINF:{:.5},", segment.duration); + let _ = writeln!(out, "seg/{}.m4s", segment.sequence); + } + } + + if snapshot.finished { + let _ = writeln!(out, "#EXT-X-ENDLIST"); + } else { + // Hint the next part at the live edge so the player can pre-request it. + let (sequence, index) = match snapshot.segments.last() { + Some(last) if !last.complete => (last.sequence, last.parts.len()), + _ => (snapshot.next_sequence, 0), + }; + let _ = writeln!(out, "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part/{sequence}/{index}.m4s\""); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::export::store::{PartMeta, SegmentMeta}; + + fn part(duration: f64, independent: bool) -> PartMeta { + PartMeta { duration, independent } + } + + #[test] + fn renders_ll_hls_tags() { + let snapshot = Snapshot { + init_ready: true, + part_target: 0.5, + media_sequence: 10, + next_sequence: 12, + segments: vec![ + SegmentMeta { + sequence: 10, + parts: vec![part(0.5, true), part(0.5, false)], + duration: 1.0, + complete: true, + discontinuity: false, + }, + SegmentMeta { + sequence: 11, + parts: vec![part(0.5, true)], + duration: 0.5, + complete: false, + discontinuity: false, + }, + ], + finished: false, + }; + + let out = render_media(&snapshot); + + assert!(out.starts_with("#EXTM3U\n#EXT-X-VERSION:9\n")); + assert!(!out.contains("#EXT-X-DISCONTINUITY")); + assert!(out.contains("#EXT-X-TARGETDURATION:1\n")); + // PART-HOLD-BACK must be >= 3x PART-TARGET. + assert!(out.contains("PART-HOLD-BACK=1.500")); + assert!(out.contains("CAN-BLOCK-RELOAD=YES")); + assert!(out.contains("#EXT-X-PART-INF:PART-TARGET=0.500\n")); + assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:10\n")); + assert!(out.contains("#EXT-X-MAP:URI=\"init.mp4\"\n")); + // First part of the complete segment is independent; the second is not. + assert!(out.contains("#EXT-X-PART:DURATION=0.50000,URI=\"part/10/0.m4s\",INDEPENDENT=YES\n")); + assert!(out.contains("#EXT-X-PART:DURATION=0.50000,URI=\"part/10/1.m4s\"\n")); + assert!(!out.contains("part/10/1.m4s\",INDEPENDENT")); + // Completed segment gets an EXTINF + segment URI. + assert!(out.contains("#EXTINF:1.00000,\nseg/10.m4s\n")); + // Live edge: preload hint points at the next (not-yet-present) part. + assert!(out.contains("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part/11/1.m4s\"\n")); + assert!(!out.contains("#EXT-X-ENDLIST")); + } + + #[test] + fn finished_playlist_has_endlist_and_no_preload() { + let snapshot = Snapshot { + init_ready: true, + part_target: 1.0, + media_sequence: 0, + next_sequence: 1, + segments: vec![SegmentMeta { + sequence: 0, + parts: vec![part(1.0, true)], + duration: 1.0, + complete: true, + discontinuity: false, + }], + finished: true, + }; + + let out = render_media(&snapshot); + assert!(out.contains("#EXT-X-ENDLIST\n")); + assert!(!out.contains("#EXT-X-PRELOAD-HINT")); + } + + #[test] + fn discontinuity_precedes_resumed_segment() { + let snapshot = Snapshot { + init_ready: true, + part_target: 1.0, + media_sequence: 0, + next_sequence: 2, + segments: vec![ + SegmentMeta { + sequence: 0, + parts: vec![part(1.0, true)], + duration: 1.0, + complete: true, + discontinuity: false, + }, + // First segment after a resume: tagged discontinuous. + SegmentMeta { + sequence: 1, + parts: vec![part(1.0, true)], + duration: 1.0, + complete: true, + discontinuity: true, + }, + ], + finished: false, + }; + + let out = render_media(&snapshot); + // The tag precedes seg 1's parts, not seg 0's. + let disc = out.find("#EXT-X-DISCONTINUITY").expect("discontinuity tag"); + let seg0 = out.find("part/0/0.m4s").expect("seg 0 part"); + let seg1 = out.find("part/1/0.m4s").expect("seg 1 part"); + assert!( + seg0 < disc && disc < seg1, + "discontinuity must sit between seg 0 and seg 1" + ); + } +} diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs new file mode 100644 index 000000000..d9e29ae3c --- /dev/null +++ b/rs/moq-hls/src/export/rendition.rs @@ -0,0 +1,179 @@ +//! One rendition: a per-track exporter pumping CMAF fragments into a store. + +use std::sync::Arc; + +use hang::catalog::{AudioConfig, VideoConfig}; +use moq_mux::catalog::{self, CatalogFormat, Filter, FilterAudio, FilterVideo}; +use moq_mux::container::fmp4::Export; +use tokio::sync::watch; + +use super::Config; +use super::store::SegmentStore; +use crate::Result; + +/// Fallback advertised bitrates when the catalog doesn't carry one. +const DEFAULT_VIDEO_BITRATE: u64 = 2_000_000; +const DEFAULT_AUDIO_BITRATE: u64 = 128_000; + +/// Whether a rendition carries video or audio (drives the store's segmenting policy). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Kind { + /// Video: a segment is a GOP, rolling on each independent fragment. + Video, + /// Audio: segments roll on accumulated duration (no keyframes). + Audio, +} + +/// A single HLS rendition: its display metadata for the master playlist plus the +/// segment/part store fed by a background exporter task. +pub struct Rendition { + /// Rendition name (the catalog track name; also its URL path component). + pub name: String, + /// Whether this rendition is video or audio. + pub kind: Kind, + /// Advertised bitrate for the master playlist `BANDWIDTH` attribute. + pub bandwidth: u64, + /// Coded width, for the master playlist `RESOLUTION` (video only). + pub width: Option, + /// Coded height, for the master playlist `RESOLUTION` (video only). + pub height: Option, + /// RFC 6381 codec string for the master playlist `CODECS` attribute. + pub codec: String, + /// The segment/part store fed by this rendition's exporter task. + pub store: Arc, +} + +impl Rendition { + /// Build a video rendition and spawn its exporter pump. + pub fn video( + name: String, + config: &VideoConfig, + broadcast: moq_net::BroadcastConsumer, + cfg: &Config, + paused: watch::Receiver, + ) -> Self { + let store = Arc::new(SegmentStore::new(Kind::Video, cfg)); + spawn_pump(broadcast, name.clone(), Kind::Video, store.clone(), cfg.clone(), paused); + Self { + name, + kind: Kind::Video, + bandwidth: config.bitrate.unwrap_or(DEFAULT_VIDEO_BITRATE), + width: config.coded_width, + height: config.coded_height, + codec: config.codec.to_string(), + store, + } + } + + /// Build an audio rendition and spawn its exporter pump. + pub fn audio( + name: String, + config: &AudioConfig, + broadcast: moq_net::BroadcastConsumer, + cfg: &Config, + paused: watch::Receiver, + ) -> Self { + let store = Arc::new(SegmentStore::new(Kind::Audio, cfg)); + spawn_pump(broadcast, name.clone(), Kind::Audio, store.clone(), cfg.clone(), paused); + Self { + name, + kind: Kind::Audio, + bandwidth: config.bitrate.unwrap_or(DEFAULT_AUDIO_BITRATE), + width: None, + height: None, + codec: config.codec.to_string(), + store, + } + } +} + +fn spawn_pump( + broadcast: moq_net::BroadcastConsumer, + name: String, + kind: Kind, + store: Arc, + cfg: Config, + paused: watch::Receiver, +) { + tokio::spawn(async move { + if let Err(err) = run_pump(broadcast, &name, &store, &cfg, paused).await { + tracing::warn!(%name, ?kind, %err, "hls rendition pump ended with error"); + } + // Whatever happened, mark the playlist closed so blocking readers wake. + store.finish(); + }); +} + +async fn run_pump( + broadcast: moq_net::BroadcastConsumer, + name: &str, + store: &SegmentStore, + cfg: &Config, + mut paused: watch::Receiver, +) -> Result<()> { + let consumer = catalog::Consumer::<()>::new(&broadcast, CatalogFormat::Hang)?; + let mut filter = Filter::new(consumer); + + // Narrow *both* axes to this rendition's name so the exporter sees exactly one + // track: the opposite axis can't hold a rendition with this name, so it empties. + filter.set_video(FilterVideo { + name: Some(name.to_string()), + ..Default::default() + }); + filter.set_audio(FilterAudio { + name: Some(name.to_string()), + ..Default::default() + }); + + // A handle for noticing the broadcast close even while paused; the `Export` + // below takes its own clone for pulling fragments. + let closed = broadcast.clone(); + + let mut export = Export::new(broadcast, filter) + .with_fragment_duration(cfg.part_target) + .with_latency(cfg.latency); + + // Whether we just resumed, so the first post-resume fragment opens a new + // continuity region (`#EXT-X-DISCONTINUITY`). + let mut resumed = false; + + loop { + // While paused, stop reading the track entirely: the relay stops sending, so + // nothing is buffered here and the publisher isn't kept ingesting for a + // receiver that isn't recording. + while *paused.borrow_and_update() { + resumed = true; + tokio::select! { + // Resume request, or the Broadcaster (and its sender) being dropped. + res = paused.changed() => { + if res.is_err() { + return Ok(()); // Broadcaster gone: stop pumping. + } + } + // The broadcast ending while paused still finalizes the track. + _ = kio::wait(|w| closed.poll_closed(w)) => return Ok(()), + } + } + + if resumed { + // The media dropped while paused is a real gap, so tag the seam. The export + // recovers on its own: the group it was mid-read on aged out of the relay + // cache while we weren't reading, and reading an evicted (or now-missing) + // group errors instead of blocking (moq-net aborts it with `Error::Old`), so + // the consumer skips the evicted span and resumes from the NEXT group still in + // the cache (`recv_group`), reading forward -- not jumping to live. + store.mark_discontinuity(); + resumed = false; + } + + // Pull one fragment uninterrupted (next_fragment isn't cancel-safe), then + // re-check the pause flag at the top of the loop -- so entering a pause costs at + // most one extra fragment (~part_target), recording right up to the pause point. + match export.next_fragment().await? { + Some(fragment) => store.push(fragment), + None => break, + } + } + + Ok(()) +} diff --git a/rs/moq-hls/src/export/store.rs b/rs/moq-hls/src/export/store.rs new file mode 100644 index 000000000..137551556 --- /dev/null +++ b/rs/moq-hls/src/export/store.rs @@ -0,0 +1,341 @@ +//! Per-rendition segment/part ring buffer. +//! +//! Consumes [`moq_mux::container::fmp4::Fragment`]s from one rendition's exporter +//! and groups them into HLS segments and LL-HLS parts, keeping a bounded sliding +//! window. A [`tokio::sync::watch`] channel notifies playlist readers (blocking +//! reload) whenever a new part or segment lands. + +use std::collections::VecDeque; +use std::sync::Mutex; + +use bytes::{Bytes, BytesMut}; +use moq_mux::container::fmp4::Fragment; +use tokio::sync::watch; + +use super::{Config, Kind}; + +/// One LL-HLS partial segment: a single CMAF moof+mdat fragment. +#[derive(Clone)] +struct Part { + data: Bytes, + duration: f64, + independent: bool, +} + +/// One HLS media segment, made of one or more [`Part`]s. For video a segment is +/// a GOP (rolls on an independent fragment); for audio it accumulates parts up to +/// a target duration. +struct Segment { + sequence: u64, + parts: Vec, + /// Total presentation duration so far (sum of part durations). + duration: f64, + /// Set once the following segment opens, so EXTINF is final. + complete: bool, + /// First segment after a pause/resume: the media timeline jumps here, so the + /// playlist precedes it with `#EXT-X-DISCONTINUITY`. + discontinuity: bool, +} + +/// Lightweight per-part metadata for rendering a playlist (no bytes). +pub struct PartMeta { + /// Part presentation duration, in seconds. + pub duration: f64, + /// Whether the part can be decoded independently (starts a keyframe). + pub independent: bool, +} + +/// Lightweight per-segment metadata for rendering a playlist (no bytes). +pub struct SegmentMeta { + /// HLS media sequence number of this segment. + pub sequence: u64, + /// Parts making up this segment, in order. + pub parts: Vec, + /// Total presentation duration so far (sum of part durations), in seconds. + pub duration: f64, + /// Whether the segment is finalized (a later segment has opened). + pub complete: bool, + /// True if this segment opens a new continuity region (post pause/resume); the + /// renderer emits `#EXT-X-DISCONTINUITY` before it. + pub discontinuity: bool, +} + +/// A point-in-time view of the store, used to render a media playlist without +/// holding the lock during formatting. +pub struct Snapshot { + /// Whether the init segment (`init.mp4`) is available. + pub init_ready: bool, + /// LL-HLS PART-TARGET, in seconds. + pub part_target: f64, + /// `EXT-X-MEDIA-SEQUENCE`: sequence of the first segment in the window. + pub media_sequence: u64, + /// Sequence the next segment to roll will be assigned. + pub next_sequence: u64, + /// Segments currently in the sliding window, oldest first. + pub segments: Vec, + /// Whether the track has ended (the playlist gains `#EXT-X-ENDLIST`). + pub finished: bool, +} + +/// Watch value: enough for a blocking-reload waiter to decide if its +/// `(_HLS_msn, _HLS_part)` target has been reached, without locking. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Version { + /// Sequence of the newest segment. + pub last_sequence: u64, + /// Number of parts in the newest segment. + pub last_parts: usize, + /// Sequence of the oldest segment still in the window. + pub media_sequence: u64, + /// Whether the track has ended. + pub finished: bool, +} + +struct Inner { + init: Option, + segments: VecDeque, + next_sequence: u64, + finished: bool, + /// Set by [`SegmentStore::mark_discontinuity`] after a resume; the next segment + /// to roll is forced open and tagged discontinuous, then this clears. + discontinuity_pending: bool, +} + +/// Bounded per-rendition store of CMAF segments and LL-HLS parts. +pub struct SegmentStore { + inner: Mutex, + notify: watch::Sender, + /// Video rolls a segment per GOP; audio rolls on duration. + kind: Kind, + /// LL-HLS PART-TARGET, in seconds. + part_target: f64, + /// Target segment duration for audio (video rolls on GOP boundaries instead). + audio_segment_target: f64, + /// Minimum duration (seconds) of media kept in the sliding window. The oldest + /// segment is evicted only while the remaining ones still cover this span. + window: f64, +} + +impl SegmentStore { + /// Create an empty store for a rendition of `kind`, taking part/segment/window + /// timing from `config`. + pub fn new(kind: Kind, config: &Config) -> Self { + let (notify, _) = watch::channel(Version::default()); + Self { + inner: Mutex::new(Inner { + init: None, + segments: VecDeque::new(), + next_sequence: 0, + finished: false, + discontinuity_pending: false, + }), + notify, + kind, + part_target: config.part_target.as_secs_f64(), + audio_segment_target: config.audio_segment_target.as_secs_f64(), + window: config.window.as_secs_f64(), + } + } + + /// Apply one exported fragment. The init fragment sets the init segment; + /// media fragments append a part (rolling a new segment per the policy). + pub fn push(&self, fragment: Fragment) { + if fragment.init { + self.inner.lock().unwrap().init = Some(fragment.data); + self.bump(); + return; + } + + { + let mut inner = self.inner.lock().unwrap(); + + // A pending discontinuity forces a fresh segment so the resumed media never + // shares a segment with pre-pause media (matters for audio, which otherwise + // rolls on duration, not keyframes). + let discontinuity = inner.discontinuity_pending; + let need_new = discontinuity + || match inner.segments.back() { + None => true, + Some(cur) => { + if self.kind == Kind::Video { + // A new GOP (independent fragment) starts a new segment. + fragment.independent + } else { + // Audio has no keyframes: roll once the segment is long enough. + cur.duration >= self.audio_segment_target + } + } + }; + + if need_new { + if let Some(cur) = inner.segments.back_mut() { + cur.complete = true; + } + let sequence = inner.next_sequence; + inner.next_sequence += 1; + inner.segments.push_back(Segment { + sequence, + parts: Vec::new(), + duration: 0.0, + complete: false, + discontinuity, + }); + inner.discontinuity_pending = false; + } + + let cur = inner.segments.back_mut().expect("segment present after need_new"); + cur.duration += fragment.duration; + cur.parts.push(Part { + data: fragment.data, + duration: fragment.duration, + independent: fragment.independent, + }); + + // Evict from the front while the newer segments still cover the window. + // Always keep the in-progress segment, so never drop below one. + while inner.segments.len() > 1 { + let total: f64 = inner.segments.iter().map(|s| s.duration).sum(); + let oldest = inner.segments.front().expect("segments non-empty").duration; + if total - oldest >= self.window { + inner.segments.pop_front(); + } else { + break; + } + } + } + + self.bump(); + } + + /// Mark that the next segment to roll begins a new continuity region. Called + /// once on each pause->resume transition: the media timeline jumps across the + /// dropped span, so the renderer emits `#EXT-X-DISCONTINUITY` before it. The + /// next [`push`](Self::push) forces a fresh segment and tags it. + pub fn mark_discontinuity(&self) { + self.inner.lock().unwrap().discontinuity_pending = true; + } + + /// Signal end-of-track. The playlist gains `#EXT-X-ENDLIST`. + pub fn finish(&self) { + { + let mut inner = self.inner.lock().unwrap(); + if let Some(cur) = inner.segments.back_mut() { + cur.complete = true; + } + inner.finished = true; + } + self.bump(); + } + + fn bump(&self) { + let version = self.version(); + // Ignore send errors: no receivers just means nobody is waiting yet. + let _ = self.notify.send(version); + } + + /// Current [`Version`] watermark (newest sequence/part counts and window edge). + pub fn version(&self) -> Version { + let inner = self.inner.lock().unwrap(); + let media_sequence = inner + .segments + .front() + .map(|s| s.sequence) + .unwrap_or(inner.next_sequence); + match inner.segments.back() { + Some(last) => Version { + last_sequence: last.sequence, + last_parts: last.parts.len(), + media_sequence, + finished: inner.finished, + }, + None => Version { + last_sequence: inner.next_sequence, + last_parts: 0, + media_sequence, + finished: inner.finished, + }, + } + } + + /// Subscribe to [`Version`] updates (one tick per new part/segment/finish). + pub fn subscribe(&self) -> watch::Receiver { + self.notify.subscribe() + } + + /// The init segment (`init.mp4`) bytes, once available. + pub fn init(&self) -> Option { + self.inner.lock().unwrap().init.clone() + } + + /// The bytes of one part (`part//.m4s`). + pub fn part(&self, sequence: u64, index: usize) -> Option { + let inner = self.inner.lock().unwrap(); + let segment = inner.segments.iter().find(|s| s.sequence == sequence)?; + segment.parts.get(index).map(|p| p.data.clone()) + } + + /// The bytes of a full segment (`seg/.m4s`): its parts concatenated. + pub fn segment(&self, sequence: u64) -> Option { + let inner = self.inner.lock().unwrap(); + let segment = inner.segments.iter().find(|s| s.sequence == sequence)?; + let mut buf = BytesMut::new(); + for part in &segment.parts { + buf.extend_from_slice(&part.data); + } + Some(buf.freeze()) + } + + /// True once the store holds the `(sequence, part)` the caller asked for, the + /// window has already advanced past it, or the track has ended. Used to decide + /// whether a blocking-reload request can be answered now. + pub fn satisfies(&self, sequence: u64, part: usize) -> bool { + let inner = self.inner.lock().unwrap(); + if inner.finished { + return true; + } + let media_sequence = inner.segments.front().map(|s| s.sequence).unwrap_or(0); + if sequence < media_sequence { + return true; // already rolled past; the playlist no longer carries it + } + match inner.segments.iter().find(|s| s.sequence == sequence) { + Some(segment) => segment.parts.len() > part || segment.complete, + None => false, + } + } + + /// Capture a lock-free view for rendering a media playlist. + pub fn snapshot(&self) -> Snapshot { + let inner = self.inner.lock().unwrap(); + let media_sequence = inner + .segments + .front() + .map(|s| s.sequence) + .unwrap_or(inner.next_sequence); + let segments = inner + .segments + .iter() + .map(|s| SegmentMeta { + sequence: s.sequence, + parts: s + .parts + .iter() + .map(|p| PartMeta { + duration: p.duration, + independent: p.independent, + }) + .collect(), + duration: s.duration, + complete: s.complete, + discontinuity: s.discontinuity, + }) + .collect(); + Snapshot { + init_ready: inner.init.is_some(), + part_target: self.part_target, + media_sequence, + next_sequence: inner.next_sequence, + segments, + finished: inner.finished, + } + } +} diff --git a/rs/moq-hls/src/import.rs b/rs/moq-hls/src/import.rs new file mode 100644 index 000000000..a586697d0 --- /dev/null +++ b/rs/moq-hls/src/import.rs @@ -0,0 +1,662 @@ +//! HLS import: pull an HLS master/media playlist and publish it into MoQ. +//! +//! Watches an HLS master or media playlist, downloads each fMP4 segment as it +//! appears, and feeds it through moq-mux's fMP4 importer (which publishes a +//! `hang` broadcast + catalog). Classic HLS only for now (no LL-HLS partial +//! segments on the import side). + +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::path::PathBuf; +use std::time::Duration; + +use bytes::Bytes; +use m3u8_rs::{ + AlternativeMedia, AlternativeMediaType, Map, MasterPlaylist, MediaPlaylist, MediaSegment, Resolution, VariantStream, +}; +use moq_mux::catalog::Producer as CatalogProducer; +use moq_mux::container::fmp4::Import as Fmp4; +use reqwest::Client; +use tracing::{debug, info, warn}; +use url::Url; + +use crate::{Error, Result}; + +/// Per-request timeout for the default HTTP client (playlist + segment fetches). +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Backoff before retrying after a failed import step, so a transient upstream +/// error (a 5xx, a truncated segment) doesn't tear down the whole import. +const ERROR_BACKOFF: Duration = Duration::from_secs(1); + +/// Configuration for the single-rendition HLS import loop. +#[derive(Clone)] +pub struct Config { + /// The master or media playlist URL or file path to import. + pub playlist: String, + + /// An optional HTTP client to use for fetching the playlist and segments. + /// If not provided, a default client will be created. + pub client: Option, +} + +impl Config { + /// Create an import configuration for `playlist` using the default HTTP client. + pub fn new(playlist: String) -> Self { + Self { playlist, client: None } + } + + /// Parse the playlist string into a URL. + /// If it starts with http:// or https://, parse as URL. + /// Otherwise, treat as a file path and convert to file:// URL. + fn parse_playlist(&self) -> Result { + if self.playlist.starts_with("http://") || self.playlist.starts_with("https://") { + Url::parse(&self.playlist).map_err(|_| Error::InvalidPlaylistUrl) + } else { + let path = PathBuf::from(&self.playlist); + let absolute = if path.is_absolute() { + path + } else { + std::env::current_dir()?.join(path) + }; + Url::from_file_path(&absolute).map_err(|_| Error::InvalidFilePath) + } + } +} + +/// Result of a single import step. +struct StepOutcome { + /// Number of media segments written during this step. + pub wrote_segments: usize, + /// Target segment duration (in seconds) from the playlist, if known. + pub target_duration: Option, +} + +/// HLS import that pulls an HLS media playlist and feeds the bytes into the fMP4 importer. +/// +/// Provides `init()` to prime the importer with initial segments, and `run()` +/// to run the continuous import loop. +pub struct Import { + /// Broadcast that all CMAF importers write into. + broadcast: moq_net::BroadcastProducer, + + /// The catalog being produced. + catalog: CatalogProducer, + + /// fMP4 importers for each discovered video rendition. + /// Each importer feeds a separate MoQ track but shares the same catalog. + video_importers: Vec, + + /// fMP4 importer for the selected audio rendition, if any. + audio_importer: Option, + + client: Client, + /// Parsed base URL for the playlist (file:// or http(s)://). + base_url: Url, + /// All discovered video variants (one per HLS rendition). + video: Vec, + /// Optional audio track shared across variants. + audio: Option, +} + +#[derive(Debug, Clone, Copy)] +enum TrackKind { + Video(usize), + Audio, +} + +struct TrackState { + playlist: Url, + next_sequence: Option, + init_ready: bool, +} + +impl TrackState { + fn new(playlist: Url) -> Self { + Self { + playlist, + next_sequence: None, + init_ready: false, + } + } +} + +impl Import { + /// Create a new HLS import that will write into the given broadcast. + pub fn new(broadcast: moq_net::BroadcastProducer, catalog: CatalogProducer, cfg: Config) -> Result { + let base_url = cfg.parse_playlist()?; + let client = match cfg.client { + Some(client) => client, + None => Client::builder() + .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) + // Bound playlist/segment fetches so a stuck request can't wedge `run()`. + .timeout(REQUEST_TIMEOUT) + .build()?, + }; + Ok(Self { + broadcast, + catalog, + video_importers: Vec::new(), + audio_importer: None, + client, + base_url, + video: Vec::new(), + audio: None, + }) + } + + /// Fetch the latest playlist, download the init segment, and prime the importer with a buffer of segments. + /// + /// Returns the number of segments buffered during initialization. + pub async fn init(&mut self) -> Result<()> { + let buffered = self.prime().await?; + if buffered == 0 { + warn!("HLS playlist had no new segments during init step"); + } else { + info!(count = buffered, "buffered initial HLS segments"); + } + Ok(()) + } + + /// Run the import loop until cancelled. + /// + /// A failed step (e.g. a transient playlist fetch error) is logged and + /// retried after a short backoff rather than ending the import. + pub async fn run(&mut self) -> Result<()> { + loop { + let outcome = match self.step().await { + Ok(outcome) => outcome, + Err(err) => { + warn!(%err, "HLS import step failed, retrying"); + tokio::time::sleep(ERROR_BACKOFF).await; + continue; + } + }; + let delay = self.refresh_delay(outcome.target_duration, outcome.wrote_segments); + + info!( + wrote_segments = outcome.wrote_segments, + target_duration = ?outcome.target_duration, + delay_secs = delay.as_secs_f32(), + "HLS import step complete" + ); + + tokio::time::sleep(delay).await; + } + } + + /// Internal: fetch the latest playlist, download the init segment, and buffer segments. + async fn prime(&mut self) -> Result { + self.ensure_tracks().await?; + + let mut buffered = 0usize; + const MAX_INIT_SEGMENTS: usize = 3; // Only process a few segments during init to avoid getting ahead of live stream + + // Prime all discovered video variants. + // + // Move the video track states out of `self` so we can safely mutate both + // the importer and the tracks without running into borrow checker issues. + let video_tracks = std::mem::take(&mut self.video); + for (index, mut track) in video_tracks.into_iter().enumerate() { + let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; + let count = self + .consume_segments(TrackKind::Video(index), &mut track, &playlist, Some(MAX_INIT_SEGMENTS)) + .await?; + buffered += count; + self.video.push(track); + } + + // Prime the shared audio track, if any. + if let Some(mut track) = self.audio.take() { + let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; + let count = self + .consume_segments(TrackKind::Audio, &mut track, &playlist, Some(MAX_INIT_SEGMENTS)) + .await?; + buffered += count; + self.audio = Some(track); + } + + Ok(buffered) + } + + /// Perform a single import step for all active tracks. + /// + /// This fetches the current media playlists, consumes any fresh segments, + /// and returns how many segments were written along with the target + /// duration to guide scheduling of the next step. + async fn step(&mut self) -> Result { + self.ensure_tracks().await?; + + let mut wrote = 0usize; + let mut target_duration = None; + + // Ingest a step from all active video variants. A single variant failing is + // logged and skipped (the track is always restored) so one bad rendition or + // segment doesn't drop the others or abort the whole step. + let video_tracks = std::mem::take(&mut self.video); + for (index, mut track) in video_tracks.into_iter().enumerate() { + match self + .ingest(TrackKind::Video(index), &mut track, &mut target_duration) + .await + { + Ok(count) => wrote += count, + Err(err) => warn!(index, %err, "video rendition import step failed, will retry"), + } + self.video.push(track); + } + + // Ingest from the shared audio track, if present. + if let Some(mut track) = self.audio.take() { + match self.ingest(TrackKind::Audio, &mut track, &mut target_duration).await { + Ok(count) => wrote += count, + Err(err) => warn!(%err, "audio rendition import step failed, will retry"), + } + self.audio = Some(track); + } + + Ok(StepOutcome { + wrote_segments: wrote, + target_duration, + }) + } + + /// Fetch one track's current media playlist and consume any fresh segments, + /// recording the playlist's target duration if not already known. + async fn ingest( + &mut self, + kind: TrackKind, + track: &mut TrackState, + target_duration: &mut Option, + ) -> Result { + let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; + if target_duration.is_none() { + *target_duration = Some(playlist.target_duration); + } + self.consume_segments(kind, track, &playlist, None).await + } + + /// Compute the delay before the next import step should run. + fn refresh_delay(&self, target_duration: Option, wrote_segments: usize) -> Duration { + let base = target_duration + .map(|dur| Duration::from_secs(dur.max(1))) + .unwrap_or_else(|| Duration::from_millis(500)); + if wrote_segments == 0 { + return base / 2; + } + + base + } + + async fn fetch_media_playlist(&self, url: Url) -> Result { + let body = self.fetch_bytes(url).await?; + + // Nom errors take ownership of the input, so we need to stringify any error messages. + let playlist = m3u8_rs::parse_media_playlist_res(&body).map_err(|e| Error::ParsePlaylist(e.to_string()))?; + + Ok(playlist) + } + + async fn ensure_tracks(&mut self) -> Result<()> { + // Tracks already discovered. + if !self.video.is_empty() { + return Ok(()); + } + + let body = self.fetch_bytes(self.base_url.clone()).await?; + if let Ok((_, master)) = m3u8_rs::parse_master_playlist(&body) { + let variants = select_variants(&master); + if variants.is_empty() { + return Err(Error::NoVariants); + } + + // Create a video track state for every usable variant. + for variant in &variants { + let video_url = resolve_uri(&self.base_url, &variant.uri)?; + self.video.push(TrackState::new(video_url)); + } + + // Choose an audio rendition based on the first variant with an audio group. + if let Some(group_id) = variants.iter().find_map(|v| v.audio.as_deref()) { + if let Some(audio_tag) = select_audio(&master, group_id) { + if let Some(uri) = &audio_tag.uri { + let audio_url = resolve_uri(&self.base_url, uri)?; + self.audio = Some(TrackState::new(audio_url)); + } else { + warn!(%group_id, "audio rendition missing URI"); + } + } else { + warn!(%group_id, "audio group not found in master playlist"); + } + } + + let audio_url = self.audio.as_ref().map(|a| a.playlist.to_string()); + info!( + video_variants = variants.len(), + audio = audio_url.as_deref().unwrap_or("none"), + "selected master playlist renditions" + ); + + return Ok(()); + } + + // Fallback: treat the provided URL as a single media playlist. + self.video.push(TrackState::new(self.base_url.clone())); + Ok(()) + } + + async fn consume_segments( + &mut self, + kind: TrackKind, + track: &mut TrackState, + playlist: &MediaPlaylist, + limit: Option, + ) -> Result { + self.ensure_init_segment(kind, track, playlist).await?; + + let next_seq = track.next_sequence.unwrap_or(0); + let playlist_seq = playlist.media_sequence; + let total_segments = playlist.segments.len(); + let last_playlist_seq = playlist_seq + total_segments as u64; + + // Both out-of-window cases re-anchor to the current playlist (skip 0) and clear + // `next_sequence` so the next push re-bases. The warning is suppressed on the + // first step (`next_sequence` still None), where starting mid-window is normal. + let skip = if next_seq > last_playlist_seq { + if track.next_sequence.is_some() { + warn!( + ?kind, + next_sequence = next_seq, + playlist_sequence = playlist_seq, + last_playlist_sequence = last_playlist_seq, + "imported ahead of playlist (upstream sequence reset?), re-anchoring to current window" + ); + } + track.next_sequence = None; + 0 + } else if next_seq < playlist_seq { + if track.next_sequence.is_some() { + warn!( + ?kind, + next_sequence = next_seq, + playlist_sequence = playlist_seq, + "next_sequence behind playlist, resetting to start of playlist" + ); + } + track.next_sequence = None; + 0 + } else { + (next_seq - playlist_seq) as usize + }; + + let available = total_segments.saturating_sub(skip); + let to_process = if let Some(max) = limit { + available.min(max) + } else { + available + }; + + info!( + ?kind, + playlist_sequence = playlist_seq, + next_sequence = next_seq, + skip = skip, + total_segments = total_segments, + to_process = to_process, + "consuming HLS segments" + ); + + if to_process > 0 { + let base_seq = playlist_seq + skip as u64; + for (i, segment) in playlist.segments[skip..skip + to_process].iter().enumerate() { + self.push_segment(kind, track, segment, base_seq + i as u64).await?; + } + info!(?kind, consumed = to_process, "consumed HLS segments"); + } else { + debug!(?kind, "no fresh HLS segments available"); + } + + Ok(to_process) + } + + async fn ensure_init_segment( + &mut self, + kind: TrackKind, + track: &mut TrackState, + playlist: &MediaPlaylist, + ) -> Result<()> { + if track.init_ready { + return Ok(()); + } + + let map = self.find_map(playlist).ok_or(Error::MissingMap)?; + + let url = resolve_uri(&track.playlist, &map.uri)?; + let bytes = self.fetch_bytes(url).await?; + let importer = match kind { + TrackKind::Video(index) => self.ensure_video_importer_for(index), + TrackKind::Audio => self.ensure_audio_importer(), + }; + + // The importer buffers internally, so a fully-parsed init segment leaves it + // initialized; any trailing partial atom just waits for the next segment. A + // segment that never yields a moov surfaces later as a decode error. + importer.decode(&bytes)?; + + track.init_ready = true; + info!(?kind, "loaded HLS init segment"); + Ok(()) + } + + async fn push_segment( + &mut self, + kind: TrackKind, + track: &mut TrackState, + segment: &MediaSegment, + sequence: u64, + ) -> Result<()> { + if segment.uri.is_empty() { + return Err(Error::EmptySegmentUri); + } + + let url = resolve_uri(&track.playlist, &segment.uri)?; + let bytes = self.fetch_bytes(url).await?; + + // `consume_segments` always runs `ensure_init_segment` before reaching here, so + // the importer is already initialized. + let importer = match kind { + TrackKind::Video(index) => self.ensure_video_importer_for(index), + TrackKind::Audio => self.ensure_audio_importer(), + }; + + importer.decode(&bytes)?; + track.next_sequence = Some(sequence + 1); + + Ok(()) + } + + fn find_map<'a>(&self, playlist: &'a MediaPlaylist) -> Option<&'a Map> { + playlist.segments.iter().find_map(|segment| segment.map.as_ref()) + } + + async fn fetch_bytes(&self, url: Url) -> Result { + if url.scheme() == "file" { + let path = url.to_file_path().map_err(|_| Error::InvalidFileUrl)?; + let bytes = tokio::fs::read(&path).await.map_err(Error::from)?; + Ok(Bytes::from(bytes)) + } else { + let response = self.client.get(url).send().await.map_err(Error::from)?; + let response = response.error_for_status().map_err(Error::from)?; + let bytes = response.bytes().await.map_err(Error::from)?; + Ok(bytes) + } + } + + /// Create or retrieve the fMP4 importer for a specific video rendition. + /// + /// Each video variant gets its own importer so that their tracks remain + /// independent while still contributing to the same shared catalog. + fn ensure_video_importer_for(&mut self, index: usize) -> &mut Fmp4 { + while self.video_importers.len() <= index { + let importer = Fmp4::new(self.broadcast.clone(), self.catalog.clone()); + self.video_importers.push(importer); + } + + self.video_importers.get_mut(index).unwrap() + } + + /// Create or retrieve the fMP4 importer for the audio rendition. + fn ensure_audio_importer(&mut self) -> &mut Fmp4 { + self.audio_importer + .get_or_insert_with(|| Fmp4::new(self.broadcast.clone(), self.catalog.clone())) + } + + #[cfg(test)] + fn has_video_importer(&self) -> bool { + !self.video_importers.is_empty() + } + + #[cfg(test)] + fn has_audio_importer(&self) -> bool { + self.audio_importer.is_some() + } +} + +fn select_audio<'a>(master: &'a MasterPlaylist, group_id: &str) -> Option<&'a AlternativeMedia> { + let mut first = None; + let mut default = None; + + for alternative in master + .alternatives + .iter() + .filter(|alt| alt.media_type == AlternativeMediaType::Audio && alt.group_id == group_id) + { + if first.is_none() { + first = Some(alternative); + } + if alternative.default { + default = Some(alternative); + break; + } + } + + default.or(first) +} + +fn select_variants(master: &MasterPlaylist) -> Vec<&VariantStream> { + // Map codec strings into a coarse "family" so we can prefer H.264 over others. + fn codec_family(codec: &str) -> Option<&'static str> { + if codec.starts_with("avc1.") || codec.starts_with("avc3.") { + Some("h264") + } else { + None + } + } + + // Extract the first *video* codec token from the CODECS attribute. A list like + // `mp4a.40.2,avc1.4d401f` (audio first) must still surface the video codec. + fn first_video_codec(variant: &VariantStream) -> Option<&str> { + let codecs = variant.codecs.as_deref()?; + codecs + .split(',') + .map(|s| s.trim()) + .find(|codec| codec_family(codec).is_some()) + } + + // Consider only non-i-frame variants with a URI and a known codec family. + let candidates: Vec<(&VariantStream, &str, &str)> = master + .variants + .iter() + .filter(|variant| !variant.is_i_frame && !variant.uri.is_empty()) + .filter_map(|variant| { + let codec = first_video_codec(variant)?; + let family = codec_family(codec)?; + Some((variant, codec, family)) + }) + .collect(); + + if candidates.is_empty() { + return Vec::new(); + } + + // Prefer families in this order, falling back to the first available. + const FAMILY_PREFERENCE: &[&str] = &["h264"]; + + let families_present: Vec<&str> = candidates.iter().map(|(_, _, fam)| *fam).collect(); + + let target_family = FAMILY_PREFERENCE + .iter() + .find(|fav| families_present.iter().any(|fam| fam == *fav)) + .copied() + .unwrap_or(families_present[0]); + + // Keep only variants in the chosen family. + let family_variants: Vec<&VariantStream> = candidates + .into_iter() + .filter(|(_, _, fam)| *fam == target_family) + .map(|(variant, _, _)| variant) + .collect(); + + // Deduplicate by resolution, keeping the lowest-bandwidth variant for each size. + let mut by_resolution: HashMap, &VariantStream> = HashMap::new(); + + for variant in family_variants { + let key = variant.resolution; + let bandwidth = variant.average_bandwidth.unwrap_or(variant.bandwidth); + + match by_resolution.entry(key) { + Entry::Vacant(entry) => { + entry.insert(variant); + } + Entry::Occupied(mut entry) => { + let existing = entry.get(); + let existing_bw = existing.average_bandwidth.unwrap_or(existing.bandwidth); + if bandwidth < existing_bw { + entry.insert(variant); + } + } + } + } + + by_resolution.values().cloned().collect() +} + +fn resolve_uri(base: &Url, value: &str) -> std::result::Result { + if let Ok(url) = Url::parse(value) { + return Ok(url); + } + + base.join(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hls_config_new_sets_fields() { + let url = "https://example.com/stream.m3u8".to_string(); + let cfg = Config::new(url.clone()); + assert_eq!(cfg.playlist, url); + } + + #[test] + fn select_variants_handles_audio_first_codecs() { + // CODECS lists the audio codec first; the video codec must still be found. + let master = b"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"mp4a.40.2,avc1.4d401f\"\nvideo.m3u8\n"; + let (_, master) = m3u8_rs::parse_master_playlist(master).unwrap(); + let variants = select_variants(&master); + assert_eq!(variants.len(), 1); + } + + #[test] + fn hls_import_starts_without_importers() { + let mut broadcast = moq_net::Broadcast::new().produce(); + let catalog = CatalogProducer::new(&mut broadcast).unwrap(); + let url = "https://example.com/master.m3u8".to_string(); + let cfg = Config::new(url); + let hls = Import::new(broadcast, catalog, cfg).unwrap(); + + assert!(!hls.has_video_importer()); + assert!(!hls.has_audio_importer()); + } +} diff --git a/rs/moq-hls/src/lib.rs b/rs/moq-hls/src/lib.rs new file mode 100644 index 000000000..c57393ff9 --- /dev/null +++ b/rs/moq-hls/src/lib.rs @@ -0,0 +1,24 @@ +//! HLS / LL-HLS <-> MoQ gateway. +//! +//! Bridges HLS (and Low-Latency HLS) and [`moq_net`] broadcasts in both +//! directions, mirroring the WHIP/WHEP split in `moq-rtc`: +//! +//! - [`import`] pulls a remote HLS master/media playlist and publishes its CMAF +//! segments into MoQ (an HTTP *client* that *publishes*). +//! - [`server`] subscribes to a MoQ broadcast and serves HLS + LL-HLS playlists +//! and CMAF segments over HTTP (an HTTP *server* that *subscribes*). +//! +//! All CMAF byte handling (import via [`moq_mux::container::fmp4::Import`], +//! export via [`moq_mux::container::fmp4::Export`]) lives in `moq-mux`; this +//! crate owns the HLS manifest generation, segment/part windowing, and the HTTP +//! surface. + +mod error; +pub mod export; +pub mod import; +#[cfg(feature = "server")] +pub mod server; + +pub use error::*; +#[cfg(feature = "server")] +pub use server::Server; diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs new file mode 100644 index 000000000..55cf68c3b --- /dev/null +++ b/rs/moq-hls/src/server/mod.rs @@ -0,0 +1,74 @@ +//! HTTP server: serves HLS / LL-HLS for MoQ broadcasts. +//! +//! Routes are path-based, so one server can expose many broadcasts: +//! +//! ```text +//! GET /{broadcast}/master.m3u8 +//! GET /{broadcast}/{rendition}/media.m3u8 (LL-HLS blocking reload via ?_HLS_msn=&_HLS_part=) +//! GET /{broadcast}/{rendition}/init.mp4 +//! GET /{broadcast}/{rendition}/seg/{seq}.m4s +//! GET /{broadcast}/{rendition}/part/{seq}/{idx}.m4s +//! ``` + +mod routes; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::Router; + +use crate::export::{Broadcaster, Config}; + +/// How long to wait for a requested broadcast to be announced by the relay. +const RESOLVE_TIMEOUT: Duration = Duration::from_secs(5); + +/// HLS export HTTP server. Cheap to clone (shared inner). +#[derive(Clone)] +pub struct Server { + inner: Arc, +} + +struct Inner { + origin: moq_net::OriginConsumer, + config: Config, + broadcasters: Mutex>>, +} + +impl Server { + /// Build a server reading broadcasts from `origin`. + pub fn new(origin: moq_net::OriginConsumer, config: Config) -> Self { + Self { + inner: Arc::new(Inner { + origin, + config, + broadcasters: Mutex::new(HashMap::new()), + }), + } + } + + /// The axum router for the HLS endpoints. + pub fn router(&self) -> Router { + routes::router(self.clone()) + } + + /// Get or create the [`Broadcaster`] for `name`, resolving the broadcast from + /// the relay (waiting briefly for its announcement). Returns `None` if the + /// broadcast never shows up. + pub(crate) async fn broadcaster(&self, name: &str) -> Option> { + if let Some(existing) = self.inner.broadcasters.lock().unwrap().get(name).cloned() { + return Some(existing); + } + + let broadcast = tokio::time::timeout(RESOLVE_TIMEOUT, self.inner.origin.announced_broadcast(name)) + .await + .ok() + .flatten()?; + + let mut broadcasters = self.inner.broadcasters.lock().unwrap(); + let broadcaster = broadcasters + .entry(name.to_string()) + .or_insert_with(|| Broadcaster::new(broadcast, self.inner.config.clone())); + Some(broadcaster.clone()) + } +} diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs new file mode 100644 index 000000000..43d08500d --- /dev/null +++ b/rs/moq-hls/src/server/routes.rs @@ -0,0 +1,172 @@ +//! axum handlers for the HLS / LL-HLS endpoints. + +use std::time::Duration; + +use axum::Router; +use axum::extract::{Path, RawQuery, State}; +use axum::http::{StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use bytes::Bytes; + +use super::Server; +use crate::export::store::SegmentStore; + +const M3U8: &str = "application/vnd.apple.mpegurl"; +const MP4: &str = "video/mp4"; + +/// How long a rendition lookup waits for the catalog to populate. +const READY_TIMEOUT: Duration = Duration::from_secs(5); +/// Upper bound on an LL-HLS blocking-reload / preload wait. +const BLOCK_TIMEOUT: Duration = Duration::from_secs(10); + +pub fn router(server: Server) -> Router { + Router::new() + .route("/{broadcast}/master.m3u8", get(master)) + .route("/{broadcast}/{rendition}/media.m3u8", get(media)) + .route("/{broadcast}/{rendition}/init.mp4", get(init)) + .route("/{broadcast}/{rendition}/seg/{file}", get(segment)) + .route("/{broadcast}/{rendition}/part/{seq}/{file}", get(part)) + .with_state(server) +} + +async fn master(State(server): State, Path(broadcast): Path) -> Response { + let Some(broadcaster) = server.broadcaster(&broadcast).await else { + return not_found(); + }; + broadcaster.wait_ready(READY_TIMEOUT).await; + m3u8(broadcaster.master_playlist()) +} + +async fn media( + State(server): State, + Path((broadcast, rendition)): Path<(String, String)>, + RawQuery(query): RawQuery, +) -> Response { + let Some(store) = store(&server, &broadcast, &rendition).await else { + return not_found(); + }; + + // LL-HLS blocking reload: wait until the requested (msn, part) lands. + if let Some(msn) = query_param(query.as_deref(), "_HLS_msn").and_then(|v| v.parse::().ok()) { + let part = query_param(query.as_deref(), "_HLS_part") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + block_until(&store, msn, part).await; + } + + let snapshot = store.snapshot(); + + // Don't advertise a rendition the player can't bootstrap yet: the playlist + // references init.mp4, which 404s until the first (init) fragment lands. + if !snapshot.init_ready { + return not_found(); + } + + m3u8(crate::export::render_media(&snapshot)) +} + +async fn init(State(server): State, Path((broadcast, rendition)): Path<(String, String)>) -> Response { + let Some(store) = store(&server, &broadcast, &rendition).await else { + return not_found(); + }; + match store.init() { + Some(bytes) => media_bytes(bytes), + None => not_found(), + } +} + +async fn segment( + State(server): State, + Path((broadcast, rendition, file)): Path<(String, String, String)>, +) -> Response { + let Some(sequence) = strip_m4s(&file).and_then(|s| s.parse::().ok()) else { + return not_found(); + }; + let Some(store) = store(&server, &broadcast, &rendition).await else { + return not_found(); + }; + match store.segment(sequence) { + Some(bytes) => media_bytes(bytes), + None => not_found(), + } +} + +async fn part( + State(server): State, + Path((broadcast, rendition, sequence, file)): Path<(String, String, u64, String)>, +) -> Response { + let Some(index) = strip_m4s(&file).and_then(|s| s.parse::().ok()) else { + return not_found(); + }; + let Some(store) = store(&server, &broadcast, &rendition).await else { + return not_found(); + }; + + // A legit preload-hint part is at most one sequence past the current last segment. + // Reject anything further ahead immediately rather than holding the connection for + // the full block timeout on a bogus/scanning request. + let version = store.version(); + if !version.finished && sequence > version.last_sequence + 1 { + return not_found(); + } + + // The part may be a preload hint that hasn't been produced yet; block briefly. + block_until(&store, sequence, index).await; + + match store.part(sequence, index) { + Some(bytes) => media_bytes(bytes), + None => not_found(), + } +} + +/// Resolve a rendition's store, waiting for the catalog to populate. +async fn store(server: &Server, broadcast: &str, rendition: &str) -> Option> { + let broadcaster = server.broadcaster(broadcast).await?; + broadcaster.wait_ready(READY_TIMEOUT).await; + broadcaster.rendition(rendition).map(|r| r.store.clone()) +} + +/// Block until the store holds `(msn, part)`, the window passed it, or the track +/// ended; bounded by [`BLOCK_TIMEOUT`]. +async fn block_until(store: &SegmentStore, msn: u64, part: usize) { + if store.satisfies(msn, part) { + return; + } + let mut rx = store.subscribe(); + let _ = tokio::time::timeout(BLOCK_TIMEOUT, async { + loop { + if store.satisfies(msn, part) { + break; + } + if rx.changed().await.is_err() { + break; + } + } + }) + .await; +} + +/// Find a query parameter value in a raw `a=b&c=d` query string. +fn query_param<'a>(query: Option<&'a str>, key: &str) -> Option<&'a str> { + query?.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=')?; + (k == key).then_some(v) + }) +} + +fn strip_m4s(file: &str) -> Option<&str> { + file.strip_suffix(".m4s") +} + +fn m3u8(body: String) -> Response { + ([(header::CONTENT_TYPE, M3U8)], body).into_response() +} + +fn media_bytes(body: Bytes) -> Response { + ([(header::CONTENT_TYPE, MP4)], body).into_response() +} + +fn not_found() -> Response { + StatusCode::NOT_FOUND.into_response() +} diff --git a/rs/moq-mux/src/container/hls/mod.rs b/rs/moq-mux/src/container/hls/mod.rs deleted file mode 100644 index f199633fd..000000000 --- a/rs/moq-mux/src/container/hls/mod.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! HLS playlist ingest. -//! -//! Watches an HLS master or media playlist, downloads each fMP4 segment -//! as it appears, and feeds it through the fMP4 importer. Import-only; -//! moq-mux doesn't emit HLS today. - -mod import; - -pub use import::*; - -/// HLS ingest errors. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum Error { - #[error("invalid playlist URL")] - InvalidPlaylistUrl, - - #[error("invalid file path")] - InvalidFilePath, - - #[error("invalid file URL")] - InvalidFileUrl, - - #[error("failed to parse media playlist: {0}")] - ParsePlaylist(String), - - #[error("no usable variants found in master playlist")] - NoVariants, - - #[error("playlist missing EXT-X-MAP")] - MissingMap, - - #[error("init segment was not fully consumed")] - InitNotConsumed, - - #[error("init segment did not initialize the importer")] - InitNotInitialized, - - #[error("encountered segment with empty URI")] - EmptySegmentUri, - - #[error("importer not initialized for {0:?} after ensure_init_segment - init segment processing failed")] - ImporterNotInitialized(String), - - #[error("url parse: {0}")] - UrlParse(#[from] url::ParseError), - - #[error("reqwest: {0}")] - Reqwest(#[from] reqwest::Error), - - #[error("io: {0}")] - Io(#[from] std::io::Error), -} From 3f355a1b3c606722b5ae2e96dd365e5dc02f505f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Jun 2026 18:52:45 -0700 Subject: [PATCH 20/34] [codex] Backport relay web embedding (#1930) --- rs/moq-relay/src/auth.rs | 15 +++++- rs/moq-relay/src/connection.rs | 5 +- rs/moq-relay/src/web.rs | 85 +++++++++++++++++++++++---------- rs/moq-relay/src/websocket.rs | 8 +--- rs/moq-relay/tests/smoke.rs | 86 ++++++++++++++++++++++++++-------- 5 files changed, 144 insertions(+), 55 deletions(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 8a5398355..b94bf21e3 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -842,6 +842,19 @@ impl Auth { AuthParams::from_url(url, &self.domains) } + /// Build a full-access token for a peer already authenticated by mTLS. + /// + /// The HTTPS/QUIC layer verifies the client certificate before calling this. + /// This method applies the relay's canonical alias resolution and internal + /// tier decision, so embedded HTTP handlers get the same authorization scope + /// as the built-in relay routes. + pub async fn verify_mtls(&self, path: &str) -> Result { + let (root, internal) = self.resolve_mtls(path).await?; + let mut token = AuthToken::unrestricted(Path::new(&root).to_owned()); + token.internal = internal; + Ok(token) + } + /// Resolve the canonical root and billing tier for an mTLS peer via the /// unified `--auth-api`. mTLS peers are already trusted (the cert is the /// credential), so this only fetches the alias + tier. @@ -856,7 +869,7 @@ impl Auth { /// canonical root (e.g. `x7k2qp`), producing a zombie session: the publisher /// believes it is connected and never reconnects, but nothing is ever served. /// Failing closed lets the client retry and self-heal once the API recovers. - pub(crate) async fn resolve_mtls(&self, path: &str) -> Result<(String, bool), AuthError> { + async fn resolve_mtls(&self, path: &str) -> Result<(String, bool), AuthError> { let Some((base, client)) = &self.auth_api else { return Ok((path.to_string(), true)); }; diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 0ccfc3daf..bf4432c40 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -2,7 +2,6 @@ use crate::{Auth, AuthError, AuthParams, AuthToken, Cluster}; use axum::http; use moq_native::Request; -use moq_net::Path; /// An error carrying the HTTP status to send when closing the request. /// @@ -136,9 +135,7 @@ impl Connection { // vanity alias lands on the same tree a JWT would; cluster peers dial // "/", which the API resolves (typically to an unscoped root). The API // also returns the billing tier (defaulting to internal for trusted peers). - let (root, internal) = self.auth.resolve_mtls(¶ms.path).await?; - let mut token = AuthToken::unrestricted(Path::new(&root).to_owned()); - token.internal = internal; + let mut token = self.auth.verify_mtls(¶ms.path).await?; // Close the session when the client certificate expires, mirroring // the JWT `exp` handling. Validated once at the TLS handshake otherwise. token.expires = identity.expiry(); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 1aec7f37b..3ff2a1a07 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -28,7 +28,7 @@ use tokio_rustls::server::TlsStream; use tower_http::cors::{Any, CorsLayer}; use tower_service::Service; -use crate::{Auth, AuthParams, AuthToken, Cluster}; +use crate::{Auth, AuthParams, Cluster}; /// Configuration for the HTTP/HTTPS web server. #[derive(Parser, Clone, Debug, serde::Deserialize, serde::Serialize, Default)] @@ -113,17 +113,47 @@ pub struct WebState { /// Run a HTTP server using Axum pub struct Web { - state: WebState, + state: Arc, config: WebConfig, } impl Web { + /// Create a web server from shared relay state and listener config. pub fn new(state: WebState, config: WebConfig) -> Self { - Self { state, config } + Self { + state: Arc::new(state), + config, + } } - /// Runs the HTTP and/or HTTPS listeners until they shut down. - pub async fn run(self) -> anyhow::Result<()> { + /// Create a web server from its relay parts. + pub fn from_parts( + auth: Auth, + cluster: Cluster, + tls_info: Arc>, + config: WebConfig, + ) -> Self { + Self::new( + WebState { + auth, + cluster, + tls_info, + conn_id: AtomicU64::new(0), + }, + config, + ) + } + + /// Return the shared state used by the default web routes. + pub fn state(&self) -> Arc { + self.state.clone() + } + + /// Build the default relay web router. + /// + /// The returned router already has relay state applied, so embedders can + /// merge in their own state-applied routers before calling [`serve`](Self::serve). + pub fn routes(&self) -> Router { let app = Router::new() .route("/health", get(serve_health)) .route("/certificate.sha256", get(serve_fingerprint)) @@ -146,11 +176,17 @@ impl Web { false => app, }; - let app = app - .fallback(serve_landing) - .layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])) - .with_state(Arc::new(self.state)) - .into_make_service(); + app.layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])) + .with_state(self.state.clone()) + } + + /// Serve `app` on the configured HTTP/HTTPS listeners until they shut down. + /// + /// This owns the listener and TLS machinery, including optional HTTPS mTLS + /// extraction. Embedders usually call [`routes`](Self::routes), merge extra + /// routes, then pass the result here. + pub async fn serve(self, app: Router) -> anyhow::Result<()> { + let app = app.fallback(serve_landing).into_make_service(); let http = if let Some(listen) = self.config.http.listen { // Dual-stack so the cert endpoint + WebSocket fallback answer over IPv4 @@ -194,6 +230,12 @@ impl Web { Ok(()) } + + /// Runs the default router on the configured listeners until they shut down. + pub async fn run(self) -> anyhow::Result<()> { + let app = self.routes(); + self.serve(app).await + } } /// Build a [`rustls::ServerConfig`] for the HTTPS listener. @@ -290,12 +332,13 @@ async fn reload_https_config(config: RustlsConfig, cert: PathBuf, key: PathBuf, } } -/// Marker inserted as a request extension when rustls verified a client cert -/// against the configured mTLS CA. We don't carry the cert bytes. "Verified -/// by our CA" is the entire signal we need (mirrors `peer_identity` on -/// the QUIC side). +/// Marker inserted as a request extension after HTTPS mTLS verifies a client certificate. +/// +/// Embedded routes can extract `Option>` to mirror the +/// built-in relay handlers, then call [`Auth::verify_mtls`] with their route +/// path when the marker is present. #[derive(Clone, Debug)] -pub(crate) struct MtlsPeer; +pub struct MtlsPeer; /// Wraps [`RustlsAcceptor`] so that, after the TLS handshake, we extract the /// peer cert presence from rustls's `ServerConnection` and attach it to every @@ -483,11 +526,7 @@ async fn serve_announced( jwt: query.jwt, }; let token = if mtls.is_some() { - // mTLS peers: the API returns the canonical root and the billing tier. - let (root, internal) = state.auth.resolve_mtls(¶ms.path).await?; - let mut token = AuthToken::unrestricted(moq_net::Path::new(&root).to_owned()); - token.internal = internal; - token + state.auth.verify_mtls(¶ms.path).await? } else { state.auth.verify(¶ms).await? }; @@ -527,11 +566,7 @@ async fn serve_fetch( jwt: params.auth.jwt, }; let token = if mtls.is_some() { - // mTLS peers: the API returns the canonical root and the billing tier. - let (root, internal) = state.auth.resolve_mtls(&auth.path).await?; - let mut token = AuthToken::unrestricted(moq_net::Path::new(&root).to_owned()); - token.internal = internal; - token + state.auth.verify_mtls(&auth.path).await? } else { state.auth.verify(&auth).await? }; diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 7300e90ec..bb6550ef1 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -16,7 +16,7 @@ use axum::{ }; use moq_net::{OriginConsumer, OriginProducer, StatsHandle, Tier}; -use crate::{AuthParams, AuthToken, WebState, web::AuthQuery, web::MtlsPeer, web::landing_response}; +use crate::{AuthParams, WebState, web::AuthQuery, web::MtlsPeer, web::landing_response}; pub(crate) async fn serve_ws( ws: Result, @@ -43,11 +43,7 @@ pub(crate) async fn serve_ws( let params = AuthParams { path, jwt: query.jwt }; let token = if mtls.is_some() { - // mTLS peers: the API returns the canonical root and the billing tier. - let (root, internal) = state.auth.resolve_mtls(¶ms.path).await?; - let mut token = AuthToken::unrestricted(moq_net::Path::new(&root).to_owned()); - token.internal = internal; - token + state.auth.verify_mtls(¶ms.path).await? } else { state.auth.verify(¶ms).await? }; diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 32daa8f59..9e54742f6 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -31,10 +31,7 @@ fn newest_lite_version() -> moq_net::Version { .expect("parse newest lite ALPN as a Version") } -/// The shared bootstrap: stand up a relay listening on `127.0.0.1:` -/// with fully public auth, and return the port plus an abort handle for the -/// spawned web server. -async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { +async fn build_web(port: u16, ws: bool) -> Web { // Crypto provider is process-global; reinstalls after the first one are // no-ops, but the test binary may run before any other moq code does. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); @@ -61,19 +58,11 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { server_config.tls.generate = vec!["localhost".into()]; let server = server_config.init().expect("server init"); - // Pick a free port for HTTP, then immediately drop the probe listener - // so axum_server can bind it. There's a tiny race window where the - // kernel could hand the same port to another process, but on localhost - // in a single-test process it's safe in practice. - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - let mut web_config = WebConfig::default(); - web_config.ws = true; + web_config.ws = ws; web_config.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); - let web = Web::new( + Web::new( WebState { auth, cluster, @@ -81,13 +70,21 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { conn_id: AtomicU64::new(0), }, web_config, - ); + ) +} - let handle = tokio::spawn(async move { - // `Web::run` only returns on error; in tests we abort it at teardown. - let _ = web.run().await; - }); +fn free_tcp_port() -> u16 { + // Pick a free port for HTTP, then immediately drop the probe listener + // so axum_server can bind it. There's a tiny race window where the + // kernel could hand the same port to another process, but on localhost + // in a single-test process it's safe in practice. + let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); + let port = probe.local_addr().expect("local addr").port(); + drop(probe); + port +} +async fn wait_for_http(port: u16, server_result: &mut tokio::sync::oneshot::Receiver>) { // Wait for axum_server to bind. A short poll is more reliable than a // fixed sleep when CI is slow. let deadline = std::time::Instant::now() + Duration::from_secs(5); @@ -95,11 +92,35 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { break; } + match server_result.try_recv() { + Ok(Ok(())) => panic!("relay web server exited before listening"), + Ok(Err(err)) => panic!("relay web server failed before listening: {err:#}"), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {} + Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { + panic!("relay web server task ended before listening") + } + } if std::time::Instant::now() >= deadline { panic!("relay http listener never became ready on port {port}"); } tokio::time::sleep(Duration::from_millis(25)).await; } +} + +/// The shared bootstrap: stand up a relay listening on `127.0.0.1:` +/// with fully public auth, and return the port plus an abort handle for the +/// spawned web server. +async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { + let port = free_tcp_port(); + let web = build_web(port, true).await; + + let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); + let handle = tokio::spawn(async move { + // `Web::run` only returns on error; in tests we abort it at teardown. + let _ = server_result_tx.send(web.run().await); + }); + + wait_for_http(port, &mut server_result_rx).await; (port, handle) } @@ -188,6 +209,33 @@ async fn relay_websocket_round_trip_uses_newest_version() { web_handle.abort(); } +#[tokio::test] +async fn relay_web_serves_merged_routes() { + tokio::time::pause(); + let port = free_tcp_port(); + let web = build_web(port, false).await; + let app = web + .routes() + .route("/embedded", axum::routing::get(|| async { "embedded\n" })); + + let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); + let handle = tokio::spawn(async move { + let _ = server_result_tx.send(web.serve(app).await); + }); + + wait_for_http(port, &mut server_result_rx).await; + + let body = reqwest::get(format!("http://127.0.0.1:{port}/embedded")) + .await + .expect("fetch embedded route") + .text() + .await + .expect("read embedded response"); + assert_eq!(body, "embedded\n"); + + handle.abort(); +} + /// A client that dials a bare `host:port` with no path must still get a /// WebSocket upgrade at the root, not the landing page. The empty path is the /// root auth scope (same as the internal listener). Regression for the From 920616871195de4956aa5edf3b5bb7e7e20fd75b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 27 Jun 2026 07:28:03 -0700 Subject: [PATCH 21/34] [codex] expose moq-rtc session runner (#1931) --- rs/moq-rtc/src/client/whep.rs | 3 +- rs/moq-rtc/src/client/whip.rs | 3 +- rs/moq-rtc/src/lib.rs | 3 +- rs/moq-rtc/src/server/mod.rs | 122 ++++++++++++++++++++++++++++++++-- rs/moq-rtc/src/server/whep.rs | 55 ++++++++------- rs/moq-rtc/src/server/whip.rs | 46 +++++++------ rs/moq-rtc/src/session.rs | 2 +- 7 files changed, 180 insertions(+), 54 deletions(-) diff --git a/rs/moq-rtc/src/client/whep.rs b/rs/moq-rtc/src/client/whep.rs index 1d815575c..2b4e45f53 100644 --- a/rs/moq-rtc/src/client/whep.rs +++ b/rs/moq-rtc/src/client/whep.rs @@ -66,7 +66,8 @@ pub(crate) async fn dial(client: &Client, url: Url, broadcast: moq_net::Broadcas let inbound = session::spawn_socket_reader(socket.clone()); let session = session::Session::ingest(rtc, socket, candidates, inbound, sink); tokio::spawn(async move { - session::log_session_end("whep client", session.run().await); + let result = session.run().await; + session::log_session_end("whep client", &result); }); Ok(()) diff --git a/rs/moq-rtc/src/client/whip.rs b/rs/moq-rtc/src/client/whip.rs index 0939528bd..a00e62e6a 100644 --- a/rs/moq-rtc/src/client/whip.rs +++ b/rs/moq-rtc/src/client/whip.rs @@ -72,7 +72,8 @@ pub(crate) async fn dial(client: &Client, url: Url, broadcast: moq_net::Broadcas let inbound = session::spawn_socket_reader(socket.clone()); let session = session::Session::egress(rtc, socket, candidates, inbound, source); tokio::spawn(async move { - session::log_session_end("whip client", session.run().await); + let result = session.run().await; + session::log_session_end("whip client", &result); }); Ok(()) diff --git a/rs/moq-rtc/src/lib.rs b/rs/moq-rtc/src/lib.rs index 3baf7a967..2bb66626b 100644 --- a/rs/moq-rtc/src/lib.rs +++ b/rs/moq-rtc/src/lib.rs @@ -30,7 +30,8 @@ //! the request path. To own the HTTP route and authorize requests yourself //! (resolving the broadcast name from a verified token), skip the routers and //! call [`whip::accept`] (ingest) / [`whep::accept`] (egress) from your own -//! handler. +//! handler. Return the [`Response::answer`] in your HTTP response, then run +//! [`Response::run`] to drive the media session for its lifetime. //! //! ## Bitstream gotcha //! diff --git a/rs/moq-rtc/src/server/mod.rs b/rs/moq-rtc/src/server/mod.rs index a82e9d904..d2846437e 100644 --- a/rs/moq-rtc/src/server/mod.rs +++ b/rs/moq-rtc/src/server/mod.rs @@ -17,21 +17,119 @@ use std::sync::{Arc, Mutex}; use axum::Router; use axum::extract::{Path, State}; -use axum::http::StatusCode; +use axum::http::{HeaderValue, StatusCode, Uri}; use tokio::sync::{OnceCell, oneshot}; -use crate::Result; +use crate::{Error, Result}; use mux::Mux; /// The result of a WHIP/WHEP [`whip::accept`] / [`whep::accept`]: the SDP answer /// to return to the client, plus an opaque resource id for the `Location` header /// (the RFC 9725 session resource URL). -#[derive(Clone, Debug)] pub struct Response { /// Opaque id identifying the negotiated session, for the `Location` header. pub resource_id: String, /// The SDP answer body (`Content-Type: application/sdp`). pub answer: String, + session: AcceptedSession, +} + +impl Response { + /// Build a negotiated session response. + pub(crate) fn new( + server: Server, + resource_id: String, + answer: String, + session: crate::session::Session, + registration: mux::Registration, + cancel: oneshot::Receiver<()>, + role: &'static str, + ) -> Self { + Self { + resource_id: resource_id.clone(), + answer, + session: AcceptedSession { + server, + resource_id, + session: Some(session), + registration: Some(registration), + cancel: Some(cancel), + role, + }, + } + } + + /// Run the negotiated media session until the peer disconnects, DELETE terminates it, or it errors. + pub async fn run(self) -> Result<()> { + self.session.run().await + } +} + +impl std::fmt::Debug for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Response") + .field("resource_id", &self.resource_id) + .field("answer", &self.answer) + .finish_non_exhaustive() + } +} + +struct AcceptedSession { + server: Server, + resource_id: String, + session: Option, + registration: Option, + cancel: Option>, + role: &'static str, +} + +impl AcceptedSession { + async fn run(mut self) -> Result<()> { + let session = self.session.take().expect("accepted session missing driver"); + let registration = self + .registration + .take() + .expect("accepted session missing mux registration"); + let cancel = self.cancel.take().expect("accepted session missing cancel receiver"); + + let result = { + let _registration = registration; + tokio::select! { + res = session.run() => { + crate::session::log_session_end(self.role, &res); + res + } + _ = cancel => { + tracing::debug!(role = self.role, "webrtc session terminated by DELETE"); + Ok(()) + } + } + }; + normalize_session_result(result) + } +} + +impl Drop for AcceptedSession { + fn drop(&mut self) { + self.server.unregister_session(&self.resource_id); + } +} + +fn normalize_session_result(result: Result<()>) -> Result<()> { + match result { + Ok(()) | Err(Error::SessionClosed) => Ok(()), + Err(err) => Err(err), + } +} + +pub(crate) fn session_location(uri: &Uri, resource_id: &str) -> Option { + let base = uri.path().trim_end_matches('/'); + let path = if base.is_empty() { + format!("/{resource_id}") + } else { + format!("{base}/{resource_id}") + }; + HeaderValue::from_str(&path).ok() } /// Configuration shared by both `server publish` and `server subscribe`. @@ -139,9 +237,9 @@ impl Server { &self.inner.subscriber } - /// Register a session under its resource id, returning the cancel receiver - /// the session task selects on. Called by [`whip::accept`] / [`whep::accept`] - /// before spawning the session. + /// Register a session under its resource id, returning the cancel receiver. + /// Called by [`whip::accept`] / [`whep::accept`] before returning the + /// negotiated session runner. pub(crate) fn register_session(&self, resource_id: String) -> oneshot::Receiver<()> { let (tx, rx) = oneshot::channel(); self.inner.sessions.lock().unwrap().insert(resource_id, tx); @@ -211,4 +309,16 @@ mod tests { server.unregister_session(id); assert!(!server.terminate(id), "unregistered session can't be terminated"); } + + #[test] + fn peer_close_is_a_successful_session_result() { + assert!(normalize_session_result(Err(Error::SessionClosed)).is_ok()); + } + + #[test] + fn session_location_preserves_mount_path() { + let uri: Uri = "/whip/live/cam0?token=secret".parse().unwrap(); + let location = session_location(&uri, "session-id").expect("header value"); + assert_eq!(location, "/whip/live/cam0/session-id"); + } } diff --git a/rs/moq-rtc/src/server/whep.rs b/rs/moq-rtc/src/server/whep.rs index b81dfd4ca..a2a3fb153 100644 --- a/rs/moq-rtc/src/server/whep.rs +++ b/rs/moq-rtc/src/server/whep.rs @@ -6,7 +6,7 @@ use axum::{ Router, body::Bytes, - extract::{Path, State}, + extract::{OriginalUri, Path, State}, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{IntoResponse, Response as HttpResponse}, routing::post, @@ -24,15 +24,29 @@ pub fn router(server: Server) -> Router { .with_state(server) } -async fn handle(server: State, path: Path, headers: HeaderMap, body: Bytes) -> HttpResponse { +async fn handle( + server: State, + path: Path, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, + body: Bytes, +) -> HttpResponse { let (server, path) = (server.0, path.0); match accept_offer(&server, &path, &headers, body).await { - Ok(Response { resource_id, answer }) => { + Ok(response) => { + let Response { + resource_id, + answer, + session, + } = response; let mut response_headers = HeaderMap::new(); response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/sdp")); - if let Ok(loc) = HeaderValue::from_str(&format!("/{path}/{resource_id}")) { + if let Some(loc) = crate::server::session_location(&uri, &resource_id) { response_headers.insert(header::LOCATION, loc); } + tokio::spawn(async move { + let _ = session.run().await; + }); (StatusCode::CREATED, response_headers, answer).into_response() } Err(err) => { @@ -63,9 +77,10 @@ async fn accept_offer(server: &Server, path: &str, headers: &HeaderMap, body: By /// enforced by moq-net exactly as for a native session; the bundled [`router`] /// passes the server's own (unauthenticated) consumer. It parses the offer, /// resolves the broadcast on `subscriber`, restricts the answer to the codecs the -/// catalog actually has, registers a media session on the shared mux, spawns the -/// MoQ->RTP session, and returns the SDP answer plus an opaque `resource_id` for -/// the WHEP `Location` header. Mirrors [`whip::accept`](super::whip::accept). +/// catalog actually has, registers a media session on the shared mux, and +/// returns the SDP answer plus an opaque `resource_id` for the WHEP `Location` +/// header. The caller must run the returned [`Response`] to drive the MoQ->RTP +/// session. Mirrors [`whip::accept`](super::whip::accept). /// /// `offer` is the raw SDP body; the caller is responsible for checking the /// `Content-Type: application/sdp` request header. Fails with [`Error::InvalidSdp`] @@ -114,25 +129,19 @@ pub async fn accept( let resource_id = sdp::new_resource_id(); let session = session::Session::egress(rtc, mux.socket(), mux.candidates().to_vec(), inbound, source); - // Register before spawning so a DELETE that races startup still finds the - // session; the task unregisters itself when it ends. + // Register before returning so a DELETE that races startup still finds the + // session; Response::run unregisters itself when it ends. let cancel = server.register_session(resource_id.clone()); - let task_server = server.clone(); - let task_resource = resource_id.clone(); - tokio::spawn(async move { - // Hold the mux registration for the session's lifetime (unregisters on exit). - let _registration = registration; - tokio::select! { - res = session.run() => session::log_session_end("whep server", res), - _ = cancel => tracing::debug!("whep session terminated by DELETE"), - } - task_server.unregister_session(&task_resource); - }); - Ok(Response { + Ok(Response::new( + server.clone(), resource_id, - answer: sdp::render_answer(&answer), - }) + sdp::render_answer(&answer), + session, + registration, + cancel, + "whep server", + )) } fn is_sdp(headers: &HeaderMap) -> bool { diff --git a/rs/moq-rtc/src/server/whip.rs b/rs/moq-rtc/src/server/whip.rs index a91f38597..a20eb7a4c 100644 --- a/rs/moq-rtc/src/server/whip.rs +++ b/rs/moq-rtc/src/server/whip.rs @@ -7,7 +7,7 @@ use axum::{ Router, body::Bytes, - extract::{Path, State}, + extract::{OriginalUri, Path, State}, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{IntoResponse, Response as HttpResponse}, routing::post, @@ -28,16 +28,25 @@ pub fn router(server: Server) -> Router { async fn handle( State(server): State, Path(path): Path, + OriginalUri(uri): OriginalUri, headers: HeaderMap, body: Bytes, ) -> HttpResponse { match accept_offer(&server, &path, &headers, body).await { - Ok(Response { resource_id, answer }) => { + Ok(response) => { + let Response { + resource_id, + answer, + session, + } = response; let mut response_headers = HeaderMap::new(); response_headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/sdp")); - if let Ok(loc) = HeaderValue::from_str(&format!("/{path}/{resource_id}")) { + if let Some(loc) = crate::server::session_location(&uri, &resource_id) { response_headers.insert(header::LOCATION, loc); } + tokio::spawn(async move { + let _ = session.run().await; + }); (StatusCode::CREATED, response_headers, answer).into_response() } Err(err) => { @@ -69,8 +78,9 @@ async fn accept_offer(server: &Server, path: &str, headers: &HeaderMap, body: By /// [`router`] passes the server's own (unauthenticated) producer. It parses the /// offer, registers the broadcast (so a fast subscriber doesn't 404 in the gap /// before the first RTP packet), registers a media session on the shared mux, -/// spawns the RTP->MoQ session, and returns the SDP answer plus an opaque -/// `resource_id` for the WHIP `Location` header. +/// and returns the SDP answer plus an opaque `resource_id` for the WHIP +/// `Location` header. The caller must run the returned [`Response`] to drive the +/// RTP->MoQ session. /// /// `offer` is the raw SDP body; the caller is responsible for checking the /// `Content-Type: application/sdp` request header. Fails with @@ -118,25 +128,19 @@ pub async fn accept( let resource_id = sdp::new_resource_id(); let session = session::Session::ingest(rtc, mux.socket(), mux.candidates().to_vec(), inbound, sink); - // Register before spawning so a DELETE that races the first packet still - // finds the session; the task unregisters itself when it ends. + // Register before returning so a DELETE that races the first packet still + // finds the session; Response::run unregisters itself when it ends. let cancel = server.register_session(resource_id.clone()); - let task_server = server.clone(); - let task_resource = resource_id.clone(); - tokio::spawn(async move { - // Hold the mux registration for the session's lifetime; it unregisters on exit. - let _registration = registration; - tokio::select! { - res = session.run() => session::log_session_end("whip server", res), - _ = cancel => tracing::debug!("whip session terminated by DELETE"), - } - task_server.unregister_session(&task_resource); - }); - Ok(Response { + Ok(Response::new( + server.clone(), resource_id, - answer: sdp::render_answer(&answer), - }) + sdp::render_answer(&answer), + session, + registration, + cancel, + "whip server", + )) } fn is_sdp(headers: &HeaderMap) -> bool { diff --git a/rs/moq-rtc/src/session.rs b/rs/moq-rtc/src/session.rs index af4272a09..d8f774bab 100644 --- a/rs/moq-rtc/src/session.rs +++ b/rs/moq-rtc/src/session.rs @@ -330,7 +330,7 @@ impl IngestClock { /// ([`Error::SessionClosed`]) is debug, a genuine failure is a warning. Keeps /// normal WebRTC churn out of the warning stream. `role` labels the path /// (e.g. `"whip server"`). -pub(crate) fn log_session_end(role: &str, result: Result<()>) { +pub(crate) fn log_session_end(role: &str, result: &Result<()>) { match result { Ok(()) | Err(Error::SessionClosed) => tracing::debug!(role, "session ended"), Err(err) => tracing::warn!(%err, role, "session ended"), From 02273a0bca1a822b43073b7270007aa0fd1c0f97 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 27 Jun 2026 08:44:51 -0700 Subject: [PATCH 22/34] [codex] support relay HTTPS cert arrays (#1932) --- CLAUDE.md | 4 +- doc/bin/relay/auth.md | 2 +- doc/bin/relay/index.md | 4 +- rs/moq-native/Cargo.toml | 2 +- rs/moq-native/src/quiche.rs | 2 +- rs/moq-native/src/tls.rs | 26 +++-- rs/moq-relay/src/config.rs | 44 +++++++++ rs/moq-relay/src/web.rs | 187 +++++++++++++++++++----------------- 8 files changed, 167 insertions(+), 104 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd7d41283..637d99a92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ The rename/removal rationale lives in the commit message and PR description, not ## AI Attribution -LLM-authored prose visible to humans (PR descriptions, PR comments, review replies) should end with `(Written by Claude)` or similar. Do **not** tag code comments, doc comments, or `/doc` pages: source markers rot. Commit attribution lives in the `Co-Authored-By` trailer, not the commit body. +LLM-authored prose visible to humans (PR descriptions, PR comments, review replies) should end with the agent model, e.g. `(Written by GPT-5)`. Do **not** tag code comments, doc comments, or `/doc` pages: source markers rot. Commit attribution lives in the `Co-Authored-By` trailer, not the commit body. ## Refactor As You Go @@ -205,4 +205,4 @@ Update them with `gh pr edit --title "..." --body "..."` whenever the scop - Bullet points in the "Summary" section that describe behavior the latest commits have changed or removed. - The test-plan checklist getting out of date as new tests are added. -When you edit a PR description you authored, keep the `(Written by Claude)` marker so reviewers still know the body wasn't human-authored. +When you edit a PR description you authored, keep the agent model marker so reviewers still know the body wasn't human-authored. diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 4f25ead0f..21ba839dd 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -231,7 +231,7 @@ Client certificate presentation is **optional**: connections without a certificate fall through to the normal JWT path unchanged. ```toml -[tls] +[server.tls] cert = ["/etc/moq/server.pem"] key = ["/etc/moq/server.key"] # One or more PEM files containing the CAs trusted to sign peer certificates. diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md index 5a9deb11e..6bb07c540 100644 --- a/doc/bin/relay/index.md +++ b/doc/bin/relay/index.md @@ -70,7 +70,7 @@ Create a `relay.toml` configuration file: [server] bind = "[::]:4443" # Listen on all interfaces, port 4443 -[tls] +[server.tls] cert = "/path/to/cert.pem" # TLS certificate key = "/path/to/key.pem" # TLS private key @@ -109,7 +109,7 @@ sudo certbot certonly --standalone -d relay.example.com Update `relay.toml`: ```toml -[tls] +[server.tls] cert = "/etc/letsencrypt/live/relay.example.com/fullchain.pem" key = "/etc/letsencrypt/live/relay.example.com/privkey.pem" ``` diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index eb6752b62..9df7dddbf 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -19,7 +19,7 @@ doctest = false default = ["quinn", "aws-lc-rs", "websocket", "tcp", "uds"] quinn = ["dep:quinn", "dep:web-transport-quinn", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch"] noq = ["dep:web-transport-noq", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch"] -quiche = ["dep:web-transport-quiche", "dep:rcgen", "dep:reqwest"] +quiche = ["dep:web-transport-quiche", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki"] # Filesystem watcher for hot-reloading on-disk TLS certs/keys; the QUIC backends imply it. watch = ["dep:notify"] aws-lc-rs = ["rustls/aws-lc-rs", "rcgen?/aws_lc_rs", "quinn?/rustls-aws-lc-rs"] diff --git a/rs/moq-native/src/quiche.rs b/rs/moq-native/src/quiche.rs index f5f1e3b2f..d51791189 100644 --- a/rs/moq-native/src/quiche.rs +++ b/rs/moq-native/src/quiche.rs @@ -329,7 +329,7 @@ impl QuicheServer { .collect(); let info = Arc::new(RwLock::new(crate::tls::Info { - #[cfg(any(feature = "noq", feature = "quinn"))] + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] certs: Vec::new(), fingerprints, })); diff --git a/rs/moq-native/src/tls.rs b/rs/moq-native/src/tls.rs index d6efe458e..2ae4ee3d3 100644 --- a/rs/moq-native/src/tls.rs +++ b/rs/moq-native/src/tls.rs @@ -5,9 +5,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{fs, io}; -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(all( + any(feature = "quinn", feature = "noq", feature = "quiche"), + any(feature = "aws-lc-rs", feature = "ring") +))] use rustls::pki_types::PrivatePkcs8KeyDer; -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] use std::sync::RwLock; /// Errors loading or generating TLS certificates and keys. @@ -77,7 +80,7 @@ pub enum Error { #[error(transparent)] Rustls(#[from] rustls::Error), - #[cfg(any(feature = "quinn", feature = "noq"))] + #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] #[error("failed to build client certificate verifier")] ClientVerifier(#[source] rustls::server::VerifierBuilderError), @@ -477,7 +480,10 @@ pub struct Server { /// and can be used by the application to grant elevated access. Clients that /// do not present a certificate are unaffected. /// - /// Only supported by the Quinn and noq backends. + /// Client certificate reporting is only supported by the Quinn and noq QUIC + /// backends. Plain-TLS listeners built via [`Self::server_config`] also use + /// these roots for optional mTLS when the feature set includes quinn, noq, or + /// quiche. #[arg( long = "server-tls-root", id = "server-tls-root", @@ -513,14 +519,14 @@ impl Server { /// `alpn` sets the advertised ALPN protocols (e.g. /// `vec![b"h2".to_vec(), b"http/1.1".to_vec()]`); pass an empty list for a /// protocol like RTMPS that doesn't use ALPN. - #[cfg(any(feature = "noq", feature = "quinn"))] + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] pub fn server_config(&self, alpn: Vec>) -> Result> { server_config(self, alpn) } } /// Build a [`rustls::ServerConfig`] from a [`Server`] for a plain-TLS listener. -#[cfg(any(feature = "noq", feature = "quinn"))] +#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] fn server_config(config: &Server, alpn: Vec>) -> Result> { let provider = crypto::provider(); @@ -591,7 +597,7 @@ impl PeerIdentity { /// TLS certificate information including fingerprints. #[derive(Debug)] pub struct Info { - #[cfg(any(feature = "noq", feature = "quinn"))] + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] pub(crate) certs: Vec>, pub fingerprints: Vec, } @@ -841,14 +847,14 @@ mod tests { // ── ServeCerts ────────────────────────────────────────────────────── -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] #[derive(Debug)] pub(crate) struct ServeCerts { pub info: Arc>, provider: crypto::Provider, } -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] impl ServeCerts { pub fn new(provider: crypto::Provider) -> Self { Self { @@ -973,7 +979,7 @@ impl ServeCerts { } } -#[cfg(any(feature = "quinn", feature = "noq"))] +#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] impl rustls::server::ResolvesServerCert for ServeCerts { fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option> { if let Some(cert) = self.best_certificate(&client_hello) { diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index bb8914e7d..b90afccb4 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -206,6 +206,50 @@ preferred_v6 = "[2001:db8::1]:443" ); } + /// Serializes tests that touch `MOQ_WEB_HTTPS_*`. Same rationale as + /// `STATS_ENV_LOCK`. + static WEB_HTTPS_ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn cli_does_not_clobber_toml_web_https_cert_arrays() { + let _guard = WEB_HTTPS_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // SAFETY: WEB_HTTPS_ENV_LOCK ensures no other test in this binary + // touches these env vars concurrently. + unsafe { + std::env::remove_var("MOQ_WEB_HTTPS_CERT"); + std::env::remove_var("MOQ_WEB_HTTPS_KEY"); + } + + let toml = r#" +[web.https] +listen = "127.0.0.1:4443" +cert = ["cdn.pem", "moq-pro.pem"] +key = ["cdn.key", "moq-pro.key"] +"#; + let dir = std::env::temp_dir().join("moq-relay-config-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("web-https-certs-toml-wins.toml"); + std::fs::write(&path, toml).unwrap(); + + let args = vec![std::ffi::OsString::from("moq-relay"), std::ffi::OsString::from(&path)]; + let config = Config::parse_and_merge(args).expect("config load"); + + assert_eq!( + config.web.https.cert, + vec![ + std::path::PathBuf::from("cdn.pem"), + std::path::PathBuf::from("moq-pro.pem") + ] + ); + assert_eq!( + config.web.https.key, + vec![ + std::path::PathBuf::from("cdn.key"), + std::path::PathBuf::from("moq-pro.key") + ] + ); + } + /// Explicit CLI flag must still override TOML. Belt-and-suspenders for the /// fix above: making `enabled: Option` shouldn't break the override /// path. diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 3ff2a1a07..9bf09e96a 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -61,7 +61,7 @@ pub struct HttpConfig { pub listen: Option, } -/// HTTPS listener configuration with TLS certificate and key. +/// HTTPS listener configuration with TLS certificates and keys. #[serde_with::serde_as] #[derive(clap::Args, Clone, Debug, Default, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields, default)] @@ -71,13 +71,32 @@ pub struct HttpsConfig { #[arg(long = "web-https-listen", id = "web-https-listen", env = "MOQ_WEB_HTTPS_LISTEN", requires_all = ["web-https-cert", "web-https-key"])] pub listen: Option, - /// Load the given certificate from disk. - #[arg(long = "web-https-cert", id = "web-https-cert", env = "MOQ_WEB_HTTPS_CERT")] - pub cert: Option, + /// Load the given certificate chain files from disk. + /// + /// In config files, accepts either a single string or a TOML array. + #[arg( + long = "web-https-cert", + id = "web-https-cert", + value_delimiter = ',', + env = "MOQ_WEB_HTTPS_CERT" + )] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde_as(as = "serde_with::OneOrMany<_>")] + pub cert: Vec, - /// Load the given key from disk. - #[arg(long = "web-https-key", id = "web-https-key", env = "MOQ_WEB_HTTPS_KEY")] - pub key: Option, + /// Load the given private key files from disk. + /// + /// Each key is paired with the certificate chain at the same index. + /// In config files, accepts either a single string or a TOML array. + #[arg( + long = "web-https-key", + id = "web-https-key", + value_delimiter = ',', + env = "MOQ_WEB_HTTPS_KEY" + )] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde_as(as = "serde_with::OneOrMany<_>")] + pub key: Vec, /// PEM file(s) of root CAs for validating optional client certificates (mTLS). /// @@ -199,12 +218,12 @@ impl Web { }; let https = if let Some(listen) = self.config.https.listen { - let cert = self.config.https.cert.expect("missing https.cert"); - let key = self.config.https.key.expect("missing https.key"); + let cert = self.config.https.cert.clone(); + let key = self.config.https.key.clone(); let root = self.config.https.root.clone(); - let config = build_https_config(&cert, &key, &root).await?; - let rustls_config = RustlsConfig::from_config(Arc::new(config)); + let config = build_https_config(&cert, &key, &root)?; + let rustls_config = RustlsConfig::from_config(config); tokio::spawn(reload_https_config(rustls_config.clone(), cert, key, root)); @@ -240,66 +259,29 @@ impl Web { /// Build a [`rustls::ServerConfig`] for the HTTPS listener. /// -/// When `root` is non-empty, installs a [`WebPkiClientVerifier`] with -/// `.allow_unauthenticated()` so JWT-only callers still complete the -/// handshake without presenting a cert. When empty, falls back to -/// [`with_no_client_auth`]. ALPN is set to `h2`, `http/1.1` to match -/// axum-server's defaults. -/// /// TLS version is left at the rustls default (1.2 + 1.3) so older clients /// can still hit the HTTPS API; the QUIC server separately forces 1.3. -async fn build_https_config( - cert: &std::path::Path, - key: &std::path::Path, +fn build_https_config( + cert: &[PathBuf], + key: &[PathBuf], root: &[PathBuf], -) -> anyhow::Result { - use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; - use rustls::server::WebPkiClientVerifier; - - let cert_chain: Vec> = CertificateDer::pem_file_iter(cert) - .context("failed to open https cert")? - .collect::>() - .context("failed to parse https cert")?; - let key_der = PrivateKeyDer::from_pem_file(key).context("failed to parse https key")?; - - let provider = rustls::crypto::CryptoProvider::get_default() - .cloned() - .expect("no default crypto provider installed"); - - let builder = - rustls::ServerConfig::builder_with_provider(provider.clone()).with_safe_default_protocol_versions()?; - - let mut config = if root.is_empty() { - builder - .with_no_client_auth() - .with_single_cert(cert_chain, key_der) - .context("invalid https cert/key pair")? - } else { - // Build the CA root store inline; `moq_native::tls::Server` is - // `non_exhaustive`, so we can't construct one to call its `load_roots`. - let mut root_store = rustls::RootCertStore::empty(); - for path in root { - let mut found = false; - for cert in CertificateDer::pem_file_iter(path).context("failed to open mTLS client CA")? { - let cert = cert.context("failed to parse mTLS client CA PEM")?; - root_store.add(cert).context("failed to add mTLS client CA")?; - found = true; - } - anyhow::ensure!(found, "no certificates found in mTLS client CA: {}", path.display()); - } - let verifier = WebPkiClientVerifier::builder_with_provider(Arc::new(root_store), provider) - .allow_unauthenticated() - .build() - .context("failed to build https client cert verifier")?; - - builder - .with_client_cert_verifier(verifier) - .with_single_cert(cert_chain, key_der) - .context("invalid https cert/key pair")? - }; - - config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - Ok(config) +) -> anyhow::Result> { + anyhow::ensure!( + !cert.is_empty(), + "web.https.cert must include at least one certificate when web.https.listen is configured" + ); + anyhow::ensure!( + cert.len() == key.len(), + "web.https.cert and web.https.key must have the same number of entries" + ); + + let mut tls = moq_native::tls::Server::default(); + tls.cert = cert.to_vec(); + tls.key = key.to_vec(); + tls.root = root.to_vec(); + + tls.server_config(vec![b"h2".to_vec(), b"http/1.1".to_vec()]) + .context("failed to build https TLS config") } /// Reload the HTTPS cert/key/root whenever they change on disk. @@ -307,9 +289,11 @@ async fn build_https_config( /// `RustlsConfig::reload_from_pem_file` would rebuild with `with_no_client_auth` /// (silently stripping mTLS when configured), so we always rebuild via the full /// [`build_https_config`] path. -async fn reload_https_config(config: RustlsConfig, cert: PathBuf, key: PathBuf, root: Vec) { - let paths: Vec = std::iter::once(cert.clone()) - .chain(std::iter::once(key.clone())) +async fn reload_https_config(config: RustlsConfig, cert: Vec, key: Vec, root: Vec) { + let paths: Vec = cert + .iter() + .cloned() + .chain(key.iter().cloned()) .chain(root.iter().cloned()) .collect(); @@ -325,8 +309,8 @@ async fn reload_https_config(config: RustlsConfig, cert: PathBuf, key: PathBuf, watcher.changed().await; tracing::info!("reloading web certificate"); - match build_https_config(&cert, &key, &root).await { - Ok(new) => config.reload_from_config(Arc::new(new)), + match build_https_config(&cert, &key, &root) { + Ok(new) => config.reload_from_config(new), Err(err) => tracing::warn!(%err, "failed to reload web certificate"), } } @@ -720,9 +704,13 @@ mod tests { use std::io::Write; use tempfile::TempDir; + fn make_certs(dir: &TempDir) -> (PathBuf, PathBuf, PathBuf) { + make_named_certs(dir, "server", "localhost") + } + /// Generate a CA + server cert/key on disk and return the temp paths. /// Modeled after `auth.rs::mtls_fixture`. - fn make_certs(dir: &TempDir) -> (PathBuf, PathBuf, PathBuf) { + fn make_named_certs(dir: &TempDir, name: &str, hostname: &str) -> (PathBuf, PathBuf, PathBuf) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let ca_kp = KeyPair::generate().unwrap(); @@ -733,15 +721,15 @@ mod tests { let ca_issuer = rcgen::Issuer::from_params(&ca_params, &ca_kp); let server_kp = KeyPair::generate().unwrap(); - let mut server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + let mut server_params = CertificateParams::new(vec![hostname.to_string()]).unwrap(); server_params .distinguished_name - .push(rcgen::DnType::CommonName, "test-server"); + .push(rcgen::DnType::CommonName, format!("test-{name}")); let server_cert = server_params.signed_by(&server_kp, &ca_issuer).unwrap(); - let ca_path = dir.path().join("ca.pem"); - let cert_path = dir.path().join("server.cert.pem"); - let key_path = dir.path().join("server.key.pem"); + let ca_path = dir.path().join(format!("{name}.ca.pem")); + let cert_path = dir.path().join(format!("{name}.cert.pem")); + let key_path = dir.path().join(format!("{name}.key.pem")); std::fs::write(&ca_path, ca_cert.pem()).unwrap(); std::fs::write(&cert_path, server_cert.pem()).unwrap(); std::fs::write(&key_path, server_kp.serialize_pem()).unwrap(); @@ -754,9 +742,8 @@ mod tests { let dir = TempDir::new().unwrap(); let (ca_path, cert_path, key_path) = make_certs(&dir); - let config = build_https_config(&cert_path, &key_path, &[ca_path]) - .await - .expect("build_https_config should succeed"); + let config = + build_https_config(&[cert_path], &[key_path], &[ca_path]).expect("build_https_config should succeed"); // ALPN must include h2 + http/1.1; otherwise reqwest's h2 attempt // would silently downgrade or fail. Mirrors axum_server's default. @@ -774,23 +761,49 @@ mod tests { // Empty root is the JWT-only path; should still produce a valid // config with ALPN set so axum-server's hyper layer can negotiate h2. - let config = build_https_config(&cert_path, &key_path, &[]) - .await - .expect("no-CA path should still build a usable config"); + let config = + build_https_config(&[cert_path], &[key_path], &[]).expect("no-CA path should still build a usable config"); assert_eq!(config.alpn_protocols, vec![b"h2".to_vec(), b"http/1.1".to_vec()],); } + #[tokio::test] + async fn build_https_config_accepts_multiple_cert_pairs() { + let dir = TempDir::new().unwrap(); + let (_ca_a, cert_a, key_a) = make_named_certs(&dir, "cdn", "cdn.moq.dev"); + let (_ca_b, cert_b, key_b) = make_named_certs(&dir, "pro", "moq.pro"); + + let config = build_https_config(&[cert_a, cert_b], &[key_a, key_b], &[]) + .expect("multiple HTTPS cert/key pairs should build"); + + assert_eq!(config.alpn_protocols, vec![b"h2".to_vec(), b"http/1.1".to_vec()]); + } + #[tokio::test] async fn build_https_config_rejects_missing_ca() { let dir = TempDir::new().unwrap(); let (_ca_path, cert_path, key_path) = make_certs(&dir); let bogus = dir.path().join("does-not-exist.pem"); - let res = build_https_config(&cert_path, &key_path, &[bogus]).await; + let res = build_https_config(&[cert_path], &[key_path], &[bogus]); assert!(res.is_err(), "missing CA file should be a hard error"); } + #[tokio::test] + async fn build_https_config_rejects_empty_cert_list() { + let res = build_https_config(&[], &[], &[]); + assert!(res.is_err(), "HTTPS must require at least one cert/key pair"); + } + + #[tokio::test] + async fn build_https_config_rejects_mismatched_cert_key_lists() { + let dir = TempDir::new().unwrap(); + let (_ca_path, cert_path, _key_path) = make_certs(&dir); + + let res = build_https_config(&[cert_path], &[], &[]); + assert!(res.is_err(), "HTTPS cert/key lists must be paired"); + } + #[tokio::test] async fn build_https_config_rejects_empty_pem() { let dir = TempDir::new().unwrap(); @@ -800,7 +813,7 @@ mod tests { let mut f = std::fs::File::create(&empty).unwrap(); writeln!(f, "# no certs here").unwrap(); - let res = build_https_config(&cert_path, &key_path, &[empty]).await; + let res = build_https_config(&[cert_path], &[key_path], &[empty]); assert!( res.is_err(), "empty PEM must be rejected to avoid a silently disabled verifier" From 060726e859e06c1a3969f2e39da09360dc12b204 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 07:27:05 -0700 Subject: [PATCH 23/34] build(deps-dev): bump the bun group with 6 updates (#1935) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- bun.lock | 216 +++++++++++++++-------------- demo/boy/package.json | 2 +- demo/web/package.json | 6 +- doc/package.json | 2 +- js/clock/package.json | 2 +- js/moq-boy/package.json | 2 +- js/net/package.json | 2 +- js/publish/package.json | 2 +- js/token/package.json | 2 +- js/watch/package.json | 2 +- test/smoke/clients/js/package.json | 2 +- 11 files changed, 124 insertions(+), 116 deletions(-) diff --git a/bun.lock b/bun.lock index 224193a27..a9cc92cdb 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ "@moq/boy": "workspace:^", }, "devDependencies": { - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "typescript": "^6.0.3", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", @@ -42,10 +42,10 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", - "@tailwindcss/vite": "^4.3.0", - "esbuild": "^0.28.0", + "@tailwindcss/vite": "^4.3.1", + "esbuild": "^0.28.1", "highlight.js": "^11.11.1", - "open": "^10.2.0", + "open": "^11.0.0", "solid-element": "^1.9.1", "solid-js": "^1.9.13", "tailwindcss": "^4.1.13", @@ -59,7 +59,7 @@ "version": "0.1.0", "devDependencies": { "vitepress": "^1.6.4", - "wrangler": "^4.101.0", + "wrangler": "^4.103.0", }, }, "js/clock": { @@ -74,7 +74,7 @@ "@moq/net": "workspace:*", }, "devDependencies": { - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "typescript": "^6.0.3", }, }, @@ -155,7 +155,7 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", "solid-js": "^1.9.13", "typescript": "^6.0.3", @@ -185,7 +185,7 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", "typescript": "^6.0.3", @@ -208,7 +208,7 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.3.14", "@typescript/lib-dom": "npm:@types/web@^0.0.350", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", "typescript": "^6.0.3", "vite": "^8.0.16", @@ -250,7 +250,7 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "rimraf": "^6.1.3", "typescript": "^6.0.3", }, @@ -269,7 +269,7 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.3.14", "@typescript/lib-dom": "npm:@types/web@^0.0.350", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", "typescript": "^6.0.3", "vite": "^8.0.16", @@ -282,7 +282,7 @@ "@moq/watch": "workspace:*", }, "devDependencies": { - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "playwright": "^1.61.0", "vite": "^8.0.16", }, @@ -398,15 +398,15 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260616.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8QaDRQABkwkwoeviNiyScol7EQgXfGsPNSyUn52GiXObthY4XPiokoJsgDSDNcAelHjEvDLmdvQBHPK8YvGn4A=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260617.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260616.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xEhiZQ62CBJ+vyKSmM13rkK/wB1kLP5sKFkF3+P+3R/c2bmnSG3Vcd5FfXUu9V0PdC+KlR02nByvZjqEw2N6Ag=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260617.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260616.1", "", { "os": "linux", "cpu": "x64" }, "sha512-p5laSYPiRUMHaLkneaZ9ZfIkNpmEnGFwgYmXtfcHJutTfEd8o3IBnsUVRSbPL+phcshKqmapLsQSxDEX6WSFfA=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260617.1", "", { "os": "linux", "cpu": "x64" }, "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260616.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-XQ7GonEl8ORvbz5fhe8Eyw2t/j09Li0KbXJxaldA318E+syF+PPTc4IRQudgqPWzzdzkH5nF7PuMOGySLSjFFw=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260617.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260616.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RaDVF9bSbPiPTq6vHYrgnv1TcQEcYnOr0WB3hWJ4yg2fBfpi2ygU6cYPuFeDwyFE9aPW5S6FBAkNmpKYueK4DQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260617.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -422,57 +422,57 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "@fails-components/webtransport": ["@fails-components/webtransport@1.6.3", "", { "dependencies": { "@types/debug": "^4.1.7", "bindings": "^1.5.0", "debug": "^4.3.4" } }, "sha512-eGeaUt1IMeDyHv1t5ocJg0e31e6w+jGDfuMZW+jNjztYkma5HHUIckmImTYwVXRIKepXe8IpQPYiFfGMCcOubA=="], @@ -742,37 +742,37 @@ "@svta/cml-utils": ["@svta/cml-utils@1.4.0", "", {}, "sha512-vNtHtv/z+9I9ysxFwNrgwxic1oceVPr8TpcpV/NA1l8Gy4phynwtOppkCIBB+PmoyKDcqE4lO85g+lfsuSTBBA=="], - "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.1", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="], "@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -810,7 +810,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@types/node": ["@types/node@26.0.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA=="], "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], @@ -1030,7 +1030,7 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -1040,7 +1040,7 @@ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1144,6 +1144,8 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], @@ -1294,7 +1296,7 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "miniflare": ["miniflare@4.20260616.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.24.8", "workerd": "1.20260616.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-cEpzoNgSWjedzYmhJvttUPmL4Jk6nSzzeNNi118T5zwnmYP9fnM8UXwFU/Qa/1qoQ4SzGqtM1Q7tinHvHvIGtw=="], + "miniflare": ["miniflare@4.20260617.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260617.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw=="], "minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="], @@ -1352,7 +1354,7 @@ "oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -1394,6 +1396,8 @@ "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "preact": ["preact@10.28.3", "", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], @@ -1594,7 +1598,7 @@ "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], - "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], + "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -1630,9 +1634,9 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], - "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -1698,9 +1702,9 @@ "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "workerd": ["workerd@1.20260616.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260616.1", "@cloudflare/workerd-darwin-arm64": "1.20260616.1", "@cloudflare/workerd-linux-64": "1.20260616.1", "@cloudflare/workerd-linux-arm64": "1.20260616.1", "@cloudflare/workerd-windows-64": "1.20260616.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-aRGWYxviSjYZwyu97pCr5GyJ9ObpgmNcfZZs3/o+kG7Wz3SBTqA8d8uhNueY5u7ADeUp2ibJvK6mXkFLrUmPgg=="], + "workerd": ["workerd@1.20260617.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260617.1", "@cloudflare/workerd-darwin-arm64": "1.20260617.1", "@cloudflare/workerd-linux-64": "1.20260617.1", "@cloudflare/workerd-linux-arm64": "1.20260617.1", "@cloudflare/workerd-windows-64": "1.20260617.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew=="], - "wrangler": ["wrangler@4.101.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260616.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260616.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260616.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-dZDDiRcT7MiA09lBDxWKmiL/iybEZ+SZe3IZmnVx1m1n1DOo730vOY5SeO7z9xFK8a/+vhGKDYB8mDXrvzEr5g=="], + "wrangler": ["wrangler@4.103.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260617.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260617.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260617.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -1708,9 +1712,9 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1764,6 +1768,8 @@ "@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -1772,7 +1778,7 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1838,6 +1844,8 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "unified-engine/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], @@ -1850,8 +1858,6 @@ "vitepress/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], - "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1874,6 +1880,8 @@ "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/concat-stream/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -1894,67 +1902,67 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "unified-engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], - "unified-engine/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], - "unified-engine/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], - "vitepress/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], - "vitepress/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], - "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], - "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], - "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], - "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], - "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], - "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], - "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], - "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], - "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], - "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], - "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], - "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], - "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], - "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], - "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], - "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], - "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], - "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], - "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], - "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], - "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], - "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + "unified-engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + "unified-engine/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + "unified-engine/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + "vitepress/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "vitepress/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/demo/boy/package.json b/demo/boy/package.json index ee9ee247d..bb4e1a4bd 100644 --- a/demo/boy/package.json +++ b/demo/boy/package.json @@ -12,7 +12,7 @@ "@moq/boy": "workspace:^" }, "devDependencies": { - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "typescript": "^6.0.3", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" diff --git a/demo/web/package.json b/demo/web/package.json index 1725edeb2..4e144418b 100644 --- a/demo/web/package.json +++ b/demo/web/package.json @@ -19,10 +19,10 @@ "//devDependencies": "These are only needed for local development with workspace packages. They are NOT needed when using the published npm packages.", "devDependencies": { "@tailwindcss/typography": "^0.5.20", - "@tailwindcss/vite": "^4.3.0", - "esbuild": "^0.28.0", + "@tailwindcss/vite": "^4.3.1", + "esbuild": "^0.28.1", "highlight.js": "^11.11.1", - "open": "^10.2.0", + "open": "^11.0.0", "solid-element": "^1.9.1", "solid-js": "^1.9.13", "tailwindcss": "^4.1.13", diff --git a/doc/package.json b/doc/package.json index bd441700d..a20bcc9a8 100644 --- a/doc/package.json +++ b/doc/package.json @@ -11,6 +11,6 @@ }, "devDependencies": { "vitepress": "^1.6.4", - "wrangler": "^4.101.0" + "wrangler": "^4.103.0" } } diff --git a/js/clock/package.json b/js/clock/package.json index dec6fcc6a..2dc0d8c76 100644 --- a/js/clock/package.json +++ b/js/clock/package.json @@ -22,7 +22,7 @@ "@fails-components/webtransport-transport-http3-quiche": "^1.6.3" }, "devDependencies": { - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "typescript": "^6.0.3" } } diff --git a/js/moq-boy/package.json b/js/moq-boy/package.json index f04a8094b..a51f05f70 100644 --- a/js/moq-boy/package.json +++ b/js/moq-boy/package.json @@ -32,7 +32,7 @@ "//devDependencies": "These are only needed for local development with workspace packages. They are NOT needed when using the published npm packages.", "devDependencies": { "@types/bun": "^1.3.14", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", "solid-js": "^1.9.13", "typescript": "^6.0.3", diff --git a/js/net/package.json b/js/net/package.json index c0d9bb97d..e1fe6f72a 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -26,7 +26,7 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", "typescript": "^6.0.3", diff --git a/js/publish/package.json b/js/publish/package.json index 917d31367..089a549cf 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -34,7 +34,7 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.3.14", "@typescript/lib-dom": "npm:@types/web@^0.0.350", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", "typescript": "^6.0.3", "vite": "^8.0.16" diff --git a/js/token/package.json b/js/token/package.json index f1436e86a..d69c3d2c7 100644 --- a/js/token/package.json +++ b/js/token/package.json @@ -26,7 +26,7 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "rimraf": "^6.1.3", "typescript": "^6.0.3" } diff --git a/js/watch/package.json b/js/watch/package.json index af848230b..7408240b8 100644 --- a/js/watch/package.json +++ b/js/watch/package.json @@ -35,7 +35,7 @@ "@types/audioworklet": "^0.0.100", "@types/bun": "^1.3.14", "@typescript/lib-dom": "npm:@types/web@^0.0.350", - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "rimraf": "^6.1.3", "typescript": "^6.0.3", "vite": "^8.0.16" diff --git a/test/smoke/clients/js/package.json b/test/smoke/clients/js/package.json index 2b6ce7b60..7fda5888c 100644 --- a/test/smoke/clients/js/package.json +++ b/test/smoke/clients/js/package.json @@ -7,7 +7,7 @@ "@moq/watch": "workspace:*" }, "devDependencies": { - "esbuild": "^0.28.0", + "esbuild": "^0.28.1", "playwright": "^1.61.0", "vite": "^8.0.16" } From 404ec17e1a9ad36d0169fc3466392add783cac7a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 28 Jun 2026 13:23:43 -0700 Subject: [PATCH 24/34] [codex] upgrade TypeScript 7 RC (#1938) --- bun.lock | 553 ++++++++++++++++++++-------------------- demo/boy/package.json | 2 +- demo/web/package.json | 2 +- infra/apt/bun.lock | 44 +++- infra/apt/package.json | 2 +- infra/rpm/bun.lock | 44 +++- infra/rpm/package.json | 2 +- js/clock/package.json | 2 +- js/flate/package.json | 2 +- js/hang/package.json | 2 +- js/json/package.json | 2 +- js/loc/package.json | 2 +- js/moq-boy/package.json | 2 +- js/msf/package.json | 2 +- js/net/package.json | 2 +- js/publish/package.json | 2 +- js/signals/package.json | 2 +- js/signals/src/dom.ts | 2 +- js/token/package.json | 2 +- js/watch/package.json | 2 +- 20 files changed, 383 insertions(+), 292 deletions(-) diff --git a/bun.lock b/bun.lock index a9cc92cdb..e6d864a15 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "moq", @@ -27,7 +26,7 @@ }, "devDependencies": { "esbuild": "^0.28.1", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", }, @@ -49,7 +48,7 @@ "solid-element": "^1.9.1", "solid-js": "^1.9.13", "tailwindcss": "^4.1.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", }, @@ -75,7 +74,7 @@ }, "devDependencies": { "@types/node": "^26.0.0", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/flate": { @@ -89,7 +88,7 @@ "@types/pako": "^2.0.4", "fflate": "^0.8.2", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/hang": { @@ -111,7 +110,7 @@ "@typescript/lib-dom": "npm:@types/web@^0.0.350", "fast-glob": "^3.3.3", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/json": { @@ -125,7 +124,7 @@ "devDependencies": { "@types/bun": "^1.3.14", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, "peerDependencies": { "zod": "^4.0.0", @@ -140,7 +139,7 @@ "devDependencies": { "@types/bun": "^1.3.14", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/moq-boy": { @@ -158,7 +157,7 @@ "esbuild": "^0.28.1", "rimraf": "^6.1.3", "solid-js": "^1.9.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", }, @@ -172,7 +171,7 @@ }, "devDependencies": { "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/net": { @@ -188,7 +187,7 @@ "@types/node": "^26.0.0", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite-plugin-html": "^3.2.2", }, "peerDependencies": { @@ -210,7 +209,7 @@ "@typescript/lib-dom": "npm:@types/web@^0.0.350", "esbuild": "^0.28.1", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", }, }, @@ -223,7 +222,7 @@ "react": "^19.0.0", "rimraf": "^6.1.3", "solid-js": "^1.9.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, "peerDependencies": { "@types/react": "^19.2.17", @@ -252,7 +251,7 @@ "@types/bun": "^1.3.14", "@types/node": "^26.0.0", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", }, }, "js/watch": { @@ -271,7 +270,7 @@ "@typescript/lib-dom": "npm:@types/web@^0.0.350", "esbuild": "^0.28.1", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", }, }, @@ -304,7 +303,7 @@ "@fails-components/webtransport-transport-http3-quiche", ], "packages": { - "@algolia/abtesting": ["@algolia/abtesting@1.14.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew=="], + "@algolia/abtesting": ["@algolia/abtesting@1.21.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-Wia5/mNTfiU0PIUN25UMfAGGdASkkwuCS9nBAdmhqrNPY/ff7U/6MgBVdwFDPsa3sA1msutPtO50gvOzx6MOXA=="], "@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="], @@ -314,99 +313,99 @@ "@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="], - "@algolia/client-abtesting": ["@algolia/client-abtesting@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw=="], + "@algolia/client-abtesting": ["@algolia/client-abtesting@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-miW8RzAtBgNiEJ9fGEhsOPgWUpekAe64YcVufqXrlykj0Jjmo5nj0a5f/HAzRVX5ZuU1GAVd7BkzFDx7q50P3A=="], - "@algolia/client-analytics": ["@algolia/client-analytics@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg=="], + "@algolia/client-analytics": ["@algolia/client-analytics@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-eR3J3kB9JX6DdCvDRi3I4KPfwO6fR9HWYRXhVke2TXIoOQafMKCRAneg33JRmIrb+DnnJ/eWApJLF1O1CLPERg=="], - "@algolia/client-common": ["@algolia/client-common@5.48.1", "", {}, "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw=="], + "@algolia/client-common": ["@algolia/client-common@5.55.1", "", {}, "sha512-P5ak7EurwYqgAiDyb95mgA3WRR/Zu8CPMv36lWTISvL2AmlPyqQPy2nX/KEJRTcwaeTWwrk6wJV4/M93GfjOWw=="], - "@algolia/client-insights": ["@algolia/client-insights@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ=="], + "@algolia/client-insights": ["@algolia/client-insights@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-OVtj9uA//+pjvKQI5INnzbyLrf3ClNv3XRbWswwJ2kHIStQNHtBfHo+LofNB/WhM9xjuXlW5ANn2aMj65UGx7w=="], - "@algolia/client-personalization": ["@algolia/client-personalization@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw=="], + "@algolia/client-personalization": ["@algolia/client-personalization@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-oKlVFlp+qbIEe4p7E54zSiP2gEV/vDu972Ykv8VDMFwEvreS7m0YKA3a8hGGHwc7yiBUGGiR3LlwzMLfnJmy6Q=="], - "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA=="], + "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-BOVrld6vdtsFmotVDMTVQfYXwrVplJ+DUvy60JFi+tkWV698q2J9NNPKEO3dr5qxtSLKQP4vHF8n+3U5PDWhOQ=="], - "@algolia/client-search": ["@algolia/client-search@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q=="], + "@algolia/client-search": ["@algolia/client-search@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-GAqHl9zERhC3bbBfubwUu07G3UXO06gORvOcsiTBZB3et0s3auNUbHlYdYNp4VKa3sUZqH5AcD3OKzU/KDGXjQ=="], - "@algolia/ingestion": ["@algolia/ingestion@1.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w=="], + "@algolia/ingestion": ["@algolia/ingestion@1.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-BXZw+C+gsWL7pZvbnhJUnCXASiDLGcQxVV7h55Pyh2DmSzwdZIVccE5xc9RVD2trtrhIqk5smuODTxtaZqd0IA=="], - "@algolia/monitoring": ["@algolia/monitoring@1.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw=="], + "@algolia/monitoring": ["@algolia/monitoring@1.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-9g/ceZrZTqA62FA3588Xj0onRPjDNfu0pVQqefK0rrHp9H6Wblph/YmzGjZ2g8uqbTh0ZGIvAGCzErU8f7MHpA=="], - "@algolia/recommend": ["@algolia/recommend@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw=="], + "@algolia/recommend": ["@algolia/recommend@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-cZTIrGyAP+W4A6jDVwvWM/JOaoJKQkD/2a5eLUEeNdKAD45jN7BCpsMDONyhZlosLa4UwL8uiINQzj4iFy9nqg=="], - "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw=="], + "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1" } }, "sha512-N6I3leW0UO8Y9Zv90yo2UHgYGuxZO0mjbvzNxDIJDjO0qECEF7Z9XMvSNeUWXQh/iNDA9lr8MfEy3rmZGIcclw=="], - "@algolia/requester-fetch": ["@algolia/requester-fetch@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg=="], + "@algolia/requester-fetch": ["@algolia/requester-fetch@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1" } }, "sha512-ukU5zeeFs44rQkzv+TRdYard+d+3lmPGs8lPZhHtWE8rfz+LlBSF6s9kP3VQ7LeOYL8Dz0u6tZfnyTrqrumbHQ=="], - "@algolia/requester-node-http": ["@algolia/requester-node-http@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg=="], + "@algolia/requester-node-http": ["@algolia/requester-node-http@5.55.1", "", { "dependencies": { "@algolia/client-common": "5.55.1" } }, "sha512-lCwXyijwPm3vbYHpBXPRomMcD6mgiptmps27gnMCf4HK+u/AOeFPBnIFh4V3l4A5SnP9VRiKBZqwGBpUH0vaTg=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@biomejs/biome": ["@biomejs/biome@2.5.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.0", "@biomejs/cli-darwin-x64": "2.5.0", "@biomejs/cli-linux-arm64": "2.5.0", "@biomejs/cli-linux-arm64-musl": "2.5.0", "@biomejs/cli-linux-x64": "2.5.0", "@biomejs/cli-linux-x64-musl": "2.5.0", "@biomejs/cli-win32-arm64": "2.5.0", "@biomejs/cli-win32-x64": "2.5.0" }, "bin": { "biome": "bin/biome" } }, "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.1", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.1", "@biomejs/cli-darwin-x64": "2.5.1", "@biomejs/cli-linux-arm64": "2.5.1", "@biomejs/cli-linux-arm64-musl": "2.5.1", "@biomejs/cli-linux-x64": "2.5.1", "@biomejs/cli-linux-x64-musl": "2.5.1", "@biomejs/cli-win32-arm64": "2.5.1", "@biomejs/cli-win32-x64": "2.5.1" }, "bin": { "biome": "bin/biome" } }, "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260617.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260625.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260617.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260625.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260617.1", "", { "os": "linux", "cpu": "x64" }, "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260625.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260617.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260625.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260617.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260625.1", "", { "os": "win32", "cpu": "x64" }, "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -416,11 +415,11 @@ "@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom", "search-insights"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], @@ -474,17 +473,17 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - "@fails-components/webtransport": ["@fails-components/webtransport@1.6.3", "", { "dependencies": { "@types/debug": "^4.1.7", "bindings": "^1.5.0", "debug": "^4.3.4" } }, "sha512-eGeaUt1IMeDyHv1t5ocJg0e31e6w+jGDfuMZW+jNjztYkma5HHUIckmImTYwVXRIKepXe8IpQPYiFfGMCcOubA=="], + "@fails-components/webtransport": ["@fails-components/webtransport@1.6.4", "", { "dependencies": { "@types/debug": "^4.1.7", "bindings": "^1.5.0", "debug": "^4.3.4" } }, "sha512-bDaJo4Ig/irjdZSGlbZr7y8MgZ885qn62iEcNxXBbaIKQTPPS124MaZdV/BisdiwpSdnjQTAuh8NN4+cnlfLhA=="], - "@fails-components/webtransport-transport-http3-quiche": ["@fails-components/webtransport-transport-http3-quiche@1.6.3", "", { "dependencies": { "@types/debug": "^4.1.7", "bindings": "^1.5.0", "cmake-js": "^8.0.0", "debug": "^4.3.4", "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1" } }, "sha512-CRolXTxtwoMGHk/o4dsW1OlVgEfkjN9DFpNEqaYABuKegUYw41MAcS9Fvcr4mkYEvFjGeYKXB/Id7rmwQErHZw=="], + "@fails-components/webtransport-transport-http3-quiche": ["@fails-components/webtransport-transport-http3-quiche@1.6.4", "", { "dependencies": { "@types/debug": "^4.1.7", "bindings": "^1.5.0", "cmake-js": "^8.0.0", "debug": "^4.3.4", "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1" } }, "sha512-kURQHNCwAw2JAv3bE2Ch9vtrYVcViBCg2g7qp0UoHQSPT4j1iRCwGv1Bs7PyrMQn/FW3IInqhZOtH191OXt7hg=="], "@hexagon/base64": ["@hexagon/base64@2.0.4", "", {}, "sha512-H/ZY6rGyaEuk0mwQgZ3BVi9hMjFTYpBNFbmtOuec/pPibuGhCMXd8fGtwBaO0h44FkWMurysMsDrpkJsBRmoWQ=="], - "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.70", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-CYNRCgN6nBTjN4dNkrBCjHXNR2e4hQihdsZUs/afUNFOWLSYjfihca4EFN05rRvDk4Xoy2n8tym6IxBZmcn+Qg=="], + "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.87", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-8YciStObhSji3OZFmWAWK6kBujyqO5bLCxeDwLxf3CR3F4PVelq7keC2LBvgTqviWzSTysj5/g4PCFLiAMVGsw=="], "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], - "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -590,19 +589,19 @@ "@moq/watch": ["@moq/watch@workspace:js/watch"], - "@moq/web-transport": ["@moq/web-transport@0.1.2", "", { "optionalDependencies": { "@moq/web-transport-darwin-arm64": "0.1.2", "@moq/web-transport-darwin-x64": "0.1.2", "@moq/web-transport-linux-arm64-gnu": "0.1.2", "@moq/web-transport-linux-x64-gnu": "0.1.2", "@moq/web-transport-win32-x64-msvc": "0.1.2" } }, "sha512-cJE0B+T5a8ey1ZgFUIBTWkIWfhCAYGAUgAw8XYeOPsKPjFhtW9M/C+XV8Yu0IeUS4nnrbBPw4sEc+TvMybV4IQ=="], + "@moq/web-transport": ["@moq/web-transport@0.1.3", "", { "optionalDependencies": { "@moq/web-transport-darwin-arm64": "0.1.3", "@moq/web-transport-darwin-x64": "0.1.3", "@moq/web-transport-linux-arm64-gnu": "0.1.3", "@moq/web-transport-linux-x64-gnu": "0.1.3", "@moq/web-transport-win32-x64-msvc": "0.1.3" } }, "sha512-hg3mKWUJaEwtJDGf8Ck62XP8ya+7wyMV/9ycV1A+M3iVly3xBOTPHpT8BrpOLomsBIB9iXQY6Z85MjknhUJW8w=="], - "@moq/web-transport-darwin-arm64": ["@moq/web-transport-darwin-arm64@0.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-waCy1fIyUg0WDwP+rI5Jg7Mw5efsJjImGZzyIDvCXM98O/nWoybPmh8Cka6VP+8xUBeclRpWmlC6phYXtYqPyw=="], + "@moq/web-transport-darwin-arm64": ["@moq/web-transport-darwin-arm64@0.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MSKd2lmgjABS6A4CoaYA2m5XibJFUi3hzz/5TTrWAiRHU8t6WV5iHcPKLMblgXi+QSZ7iLlEL0tXzmN9u0qkJw=="], - "@moq/web-transport-darwin-x64": ["@moq/web-transport-darwin-x64@0.1.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-MWllJfxoZ5ncFxuWpcrwIEMpNjvixt7ycCZQDoW9yjfqsMzfPVRaunvJ8kW0zp0IXrGpC/RFeecFSaE7PmzHpg=="], + "@moq/web-transport-darwin-x64": ["@moq/web-transport-darwin-x64@0.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Kml/EUk8EWiKNkBRcLR8JEpMOqxEpDEHHaDlLd+ro84BY/MAgiJGiVKebGZI6QP2C2OalfAqZZQ0Eh8bB6w62Q=="], - "@moq/web-transport-linux-arm64-gnu": ["@moq/web-transport-linux-arm64-gnu@0.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-cjVoLj/SGjetlJx3tbVt0fhzYIN195L9JQ/UkZqpvkhIfWEopPX7MzCWUPRK72mzH7kCyWr0dUGSu7B3pD1j1w=="], + "@moq/web-transport-linux-arm64-gnu": ["@moq/web-transport-linux-arm64-gnu@0.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3/Uko9N3X1b68hTgKVrLwpbMgtbvert0ADvIoziE1wczMcoORsys6dE9APH1jeZaXYgLAN5sVf7fdSMFI6HAQ=="], - "@moq/web-transport-linux-x64-gnu": ["@moq/web-transport-linux-x64-gnu@0.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ExZbJFdUeL2G0FkBY7mw/tZTq6+lZfYgIWkjDRa4YkifcGjMevEgpsby5QXlxhs+Og1y5q/x2Xgzd/bAG3TkRQ=="], + "@moq/web-transport-linux-x64-gnu": ["@moq/web-transport-linux-x64-gnu@0.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-WVFRgrhrQUWEKM4uBgBwfz1vGmGOU7DtM38VqlW1+6+bEm6+a0jMJ/6HUBz1bEdzzKZsVcVoT66cZoymyvzjHA=="], - "@moq/web-transport-win32-x64-msvc": ["@moq/web-transport-win32-x64-msvc@0.1.2", "", { "os": "win32", "cpu": "x64" }, "sha512-iypZ2WVoqEDB4dt7qG/wRj3zWAEqpoPP1Lj44L3o23g60UGuqsrGgbpy85qmejBkjMmDrWHr0Phx6pYJNAj/TA=="], + "@moq/web-transport-win32-x64-msvc": ["@moq/web-transport-win32-x64-msvc@0.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yPWBiujSW4bOByb1kfVyBd28MWoLpKneqel4k5KqW941Ofc6H/czegvtmDiFswx1KH531EjKljmuyilH+2g8Rw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -622,7 +621,7 @@ "@npmcli/promise-spawn": ["@npmcli/promise-spawn@7.0.2", "", { "dependencies": { "which": "^4.0.0" } }, "sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ=="], - "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -632,91 +631,91 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - "@publint/pack": ["@publint/pack@0.1.4", "", {}, "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ=="], + "@publint/pack": ["@publint/pack@0.1.5", "", { "dependencies": { "tinyexec": "^1.2.4" } }, "sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], "@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="], @@ -736,11 +735,11 @@ "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.2", "", { "peerDependencies": { "@svta/cml-utils": "1.5.0" } }, "sha512-c9UgY1z16zgvTEa6fS++9E9zxLuPtLJ4Dw5ebOgEDtwIw+PIhbh3URGXjv30tyDU2QQTXstI6U1q6h/Q4SkxGQ=="], - "@svta/cml-utils": ["@svta/cml-utils@1.4.0", "", {}, "sha512-vNtHtv/z+9I9ysxFwNrgwxic1oceVPr8TpcpV/NA1l8Gy4phynwtOppkCIBB+PmoyKDcqE4lO85g+lfsuSTBBA=="], + "@svta/cml-utils": ["@svta/cml-utils@1.5.0", "", {}, "sha512-JMqclD7Akd+GSJiuaYNUHOP2wNtf/nauKeszlYeivSHfi0Lp3pmSW5PXDvJ2dO4aPmmSOQbF0ztTsW9Vcs2Whw=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], @@ -774,7 +773,7 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/audioworklet": ["@types/audioworklet@0.0.100", "", {}, "sha512-mWt4Z5CWi0CupDnSYWJgx6dw61XUk9JSRGrM9F/YNrpOoY/0XG+sxP+8rAjQq80H4febOe8PrzGmmN/X3w8LsQ=="], @@ -790,9 +789,9 @@ "@types/concat-stream": ["@types/concat-stream@2.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ=="], - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -810,7 +809,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@26.0.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA=="], + "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], @@ -826,35 +825,75 @@ "@typescript/lib-dom": ["@types/web@0.0.350", "", {}, "sha512-XFISASo1wPsKKtI5v1nJ51YuZYkjUE9kduDFpfHCOKEMbHtRLHX0k3V3bpBeQx2L1VvcxTIECHvC3r3Rfmu0YA=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.1-rc", "", { "os": "aix", "cpu": "ppc64" }, "sha512-oqq2ZfEJ7BQuufcC3QBQndZLPNyamYNHLao8lKRBeeSkZKypBqxPSgkzrcFZtbYcIaBvpiyUnQP9MT7DEYHWbw=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.1-rc", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Slc0yTftT2F/uGDmtPst8ijydneL6uZaLEyr2UjahxZpbhTjHFBJ5agXtVz/TL4A+ldxzjzj+E8QtLZlh/5mXw=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.1-rc", "", { "os": "darwin", "cpu": "x64" }, "sha512-h68iFW/LbA1/BsGgSRGFw981/3s1f/rY27YrmeZNuN+ly7dI+fiDduwT9ZT9866x2onoKNRq7PTyxSKyKDzfAQ=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.1-rc", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DE+ppd8Ix2c6OMuRkKY4PJ4hngMGJ9M95OQUP17p9xL/1IKXof7npIeuusMN/bgL5o5JzMfSGh+N+5scTYRg0Q=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.1-rc", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ST1ozHMw0u+CLOnWkcTyWDMV4Qn9osZ6fd1V1lnKDM1t0hZIp81mdGpdHxyHJjd7jdGrb6Gb/QXcZ1uqZ0t5zw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.1-rc", "", { "os": "linux", "cpu": "arm" }, "sha512-gHmHwT5Naq5CKM8g9bbaGeEpnwQEvWCLn3fwP4K2m61VQdDKkPk0Dhab/OoZ4LV2SrMddmclYXTzpyg23YGt5g=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.1-rc", "", { "os": "linux", "cpu": "arm64" }, "sha512-N46pRihK3t5zD5MUtTQcdmQUqr1WI4U2nxno1gLwOtRSsB4krFkRjPHcQNG7h2DtRkX64rQiReX6WKwg2wprMA=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-G17Sao312rgiPBTh2F4nOpLpa3CcnBSaNhqNghZk2LNhnsp1RaMO5HMq2me21gqu9xLpc6CIgHtOzU6JBgNlfg=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-0FQspOb5UsQ4tQKvWgUO3pS9OIWkP7/8dPRWq+CRazJUeQZ4LBjtYK52jg5iIOrvItrVl2CwvRtrU3/9OihwJg=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.1-rc", "", { "os": "linux", "cpu": "ppc64" }, "sha512-SUmwfVBEv6A2Ld0eWfcvW0FqrgemfQL8jFGOmV1qYxsDqumjE5DekHXqbstgmbE4SHr4rrjHjvmuGCY+kTH/vw=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-rxeqnNnGiYzv/LlPHi/3+4p0ooR1cNJLjRIHXKovtiVmxXGJq6gtw8VSpbHuWPekyFMXgIAoLCZN0SQ51rAALQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.1-rc", "", { "os": "linux", "cpu": "s390x" }, "sha512-RYWCHCiPypxajdRHM2CNK/eM22e4Ex5TTjV2pXf7PTtBowGr0xX8i8kIMknyZS0LX2QfleYHouaoMVsFDSle3g=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.1-rc", "", { "os": "linux", "cpu": "x64" }, "sha512-PfLJSu0JzroDkqw2m4nqflPEcn8yev0m/vHFQlY9EzHorzjR6QG0wL8AJHvnD1e6h1s76AZngJ5u+z1K/D/HKw=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.1-rc", "", { "os": "none", "cpu": "arm64" }, "sha512-FfbPxH3dTfp8yVIaNM7bdWTixXuyxpzoemluqcqMROSIz+ImpCG3Q9HO9Ptzp9/giv+P9YYEnCMSXh61migj2w=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.1-rc", "", { "os": "none", "cpu": "x64" }, "sha512-FzdTfSzhRYb6hlav6K3cI5RVgcvCTvNAu/vc+t7B6AmZkThQ+t/1ntnvT5fnHmY1Az2RIBw7/b+qtCEG61HJTQ=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.1-rc", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-PQGhlxfNig+0YQ9Wwzd0USPBkt6w/ZqkBQWsU7G/0JkTzunJel+jSWwhKw4947pak/m7hGSeYiI04xReDLGZww=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.1-rc", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WJ7NYgO2mHmLUkI/tZ+hl8lFd26QPJqO8ONOHNuYbdsybLvSB6B6sep222JIVrOfPRDGvFinbGGB+l3m1FWRWA=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.1-rc", "", { "os": "sunos", "cpu": "x64" }, "sha512-UYjDeUxd765V9qcwlUPk4pEXyL0i3G76CJm9baK4i99u1pGO1psf3nXDw4MMmElVOPvGbZag99ZR/O59E2OX6w=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.1-rc", "", { "os": "win32", "cpu": "arm64" }, "sha512-KzXzFSXZOm7zvEt2Aw0MsB2LbTL88znAiVqTDNAOHdlEb7brgmUQh/X2wM/8Be+N0fjEqWKl65cBKNwpWEbJiw=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.1-rc", "", { "os": "win32", "cpu": "x64" }, "sha512-98R3+OqDr/r0/PLWEoXu88AE0lGVLNd335Ew8ONgzK1JWkNs4ou/5BGt3Or1ij4iXjH+c7PRL+jFjCbtWze+EA=="], + "@ungap/global-this": ["@ungap/global-this@0.4.4", "", {}, "sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], - "@vue/compiler-core": ["@vue/compiler-core@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.28", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], - "@vue/compiler-dom": ["@vue/compiler-dom@3.5.28", "", { "dependencies": { "@vue/compiler-core": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA=="], + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], - "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.28", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.28", "@vue/compiler-dom": "3.5.28", "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g=="], + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.39", "@vue/compiler-dom": "3.5.39", "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg=="], - "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g=="], + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="], - "@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="], + "@vue/devtools-api": ["@vue/devtools-api@7.7.10", "", { "dependencies": { "@vue/devtools-kit": "^7.7.10" } }, "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA=="], - "@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="], + "@vue/devtools-kit": ["@vue/devtools-kit@7.7.10", "", { "dependencies": { "@vue/devtools-shared": "^7.7.10", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw=="], - "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="], + "@vue/devtools-shared": ["@vue/devtools-shared@7.7.10", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ=="], - "@vue/reactivity": ["@vue/reactivity@3.5.28", "", { "dependencies": { "@vue/shared": "3.5.28" } }, "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw=="], + "@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="], - "@vue/runtime-core": ["@vue/runtime-core@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ=="], + "@vue/runtime-core": ["@vue/runtime-core@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw=="], - "@vue/runtime-dom": ["@vue/runtime-dom@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/runtime-core": "3.5.28", "@vue/shared": "3.5.28", "csstype": "^3.2.3" } }, "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA=="], + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/runtime-core": "3.5.39", "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww=="], - "@vue/server-renderer": ["@vue/server-renderer@3.5.28", "", { "dependencies": { "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "vue": "3.5.28" } }, "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg=="], + "@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="], - "@vue/shared": ["@vue/shared@3.5.28", "", {}, "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ=="], + "@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], "@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="], @@ -866,9 +905,9 @@ "abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - "algoliasearch": ["algoliasearch@5.48.1", "", { "dependencies": { "@algolia/abtesting": "1.14.1", "@algolia/client-abtesting": "5.48.1", "@algolia/client-analytics": "5.48.1", "@algolia/client-common": "5.48.1", "@algolia/client-insights": "5.48.1", "@algolia/client-personalization": "5.48.1", "@algolia/client-query-suggestions": "5.48.1", "@algolia/client-search": "5.48.1", "@algolia/ingestion": "1.48.1", "@algolia/monitoring": "1.48.1", "@algolia/recommend": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg=="], + "algoliasearch": ["algoliasearch@5.55.1", "", { "dependencies": { "@algolia/abtesting": "1.21.1", "@algolia/client-abtesting": "5.55.1", "@algolia/client-analytics": "5.55.1", "@algolia/client-common": "5.55.1", "@algolia/client-insights": "5.55.1", "@algolia/client-personalization": "5.55.1", "@algolia/client-query-suggestions": "5.55.1", "@algolia/client-search": "5.55.1", "@algolia/ingestion": "1.55.1", "@algolia/monitoring": "1.55.1", "@algolia/recommend": "5.55.1", "@algolia/requester-browser-xhr": "5.55.1", "@algolia/requester-fetch": "5.55.1", "@algolia/requester-node-http": "5.55.1" } }, "sha512-FyaFnnsbVPtevQwqSj/SdxE3jAsSsY0BEH8IVLf9rXxEBdAhAmT6VKCVSMWoaPIHVN1Eufh/1w8q6k8URpIkWw=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -880,17 +919,17 @@ "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], - "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], - "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -904,11 +943,11 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -920,7 +959,7 @@ "camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="], - "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -962,7 +1001,7 @@ "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], - "concurrently": ["concurrently@10.0.3", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.8.4", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "concurrently": "dist/bin/index.js", "conc": "dist/bin/index.js" } }, "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA=="], + "concurrently": ["concurrently@10.0.3", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.8.4", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA=="], "connect-history-api-fallback": ["connect-history-api-fallback@1.6.0", "", {}, "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg=="], @@ -1022,7 +1061,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], + "electron-to-chromium": ["electron-to-chromium@1.5.380", "", {}, "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -1064,7 +1103,7 @@ "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -1088,7 +1127,7 @@ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "glob": ["glob@13.0.3", "", { "dependencies": { "minimatch": "^10.2.0", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1162,7 +1201,7 @@ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -1174,7 +1213,7 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -1210,7 +1249,7 @@ "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], - "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1296,13 +1335,13 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "miniflare": ["miniflare@4.20260617.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260617.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw=="], + "miniflare": ["miniflare@4.20260625.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260625.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA=="], - "minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], @@ -1318,21 +1357,21 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], - "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + "node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "node-api-headers": ["node-api-headers@1.8.0", "", {}, "sha512-jfnmiKWjRAGbdD1yQS28bknFM1tbHC1oucyuMPjmkEs+kpiu76aRs40WlTmBmyEgzDM76ge1DQ7XJ3R5deiVjQ=="], + "node-api-headers": ["node-api-headers@1.9.0", "", {}, "sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA=="], "node-html-parser": ["node-html-parser@5.4.2", "", { "dependencies": { "css-select": "^4.2.1", "he": "1.2.0" } }, "sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw=="], - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], "nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -1374,7 +1413,7 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -1386,19 +1425,19 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "playwright": ["playwright@1.61.0", "", { "dependencies": { "playwright-core": "1.61.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], - "playwright-core": ["playwright-core@1.61.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA=="], + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], - "preact": ["preact@10.28.3", "", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="], + "preact": ["preact@10.29.3", "", {}, "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], @@ -1408,11 +1447,11 @@ "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "publint": ["publint@0.3.21", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ=="], - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1516,9 +1555,9 @@ "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], - "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], - "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -1534,9 +1573,9 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], + "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], - "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -1596,22 +1635,24 @@ "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], + "tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="], - "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], - "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], + "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1632,7 +1673,7 @@ "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@7.0.1-rc", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.1-rc", "@typescript/typescript-darwin-arm64": "7.0.1-rc", "@typescript/typescript-darwin-x64": "7.0.1-rc", "@typescript/typescript-freebsd-arm64": "7.0.1-rc", "@typescript/typescript-freebsd-x64": "7.0.1-rc", "@typescript/typescript-linux-arm": "7.0.1-rc", "@typescript/typescript-linux-arm64": "7.0.1-rc", "@typescript/typescript-linux-loong64": "7.0.1-rc", "@typescript/typescript-linux-mips64el": "7.0.1-rc", "@typescript/typescript-linux-ppc64": "7.0.1-rc", "@typescript/typescript-linux-riscv64": "7.0.1-rc", "@typescript/typescript-linux-s390x": "7.0.1-rc", "@typescript/typescript-linux-x64": "7.0.1-rc", "@typescript/typescript-netbsd-arm64": "7.0.1-rc", "@typescript/typescript-netbsd-x64": "7.0.1-rc", "@typescript/typescript-openbsd-arm64": "7.0.1-rc", "@typescript/typescript-openbsd-x64": "7.0.1-rc", "@typescript/typescript-sunos-x64": "7.0.1-rc", "@typescript/typescript-win32-arm64": "7.0.1-rc", "@typescript/typescript-win32-x64": "7.0.1-rc" }, "bin": { "tsc": "bin/tsc" } }, "sha512-drEP77wK7CCDlPfXZH4e008UUQOsw1DFmHmZOZjuNA+yoDLLnSNMZRXi90NbV/1LVo7SbNLq1bs3jjvk49TEqQ=="], "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], @@ -1686,25 +1727,25 @@ "vfile-statistics": ["vfile-statistics@3.0.0", "", { "dependencies": { "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-/qlwqwWBWFOmpXujL/20P+Iuydil0rZZNglR+VNm6J0gpLHwuVM5s7g2TfVoswbXjZ4HuIhLMySEyIw5i7/D8w=="], - "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], "vite-plugin-html": ["vite-plugin-html@3.2.2", "", { "dependencies": { "@rollup/pluginutils": "^4.2.0", "colorette": "^2.0.16", "connect-history-api-fallback": "^1.6.0", "consola": "^2.15.3", "dotenv": "^16.0.0", "dotenv-expand": "^8.0.2", "ejs": "^3.1.6", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "html-minifier-terser": "^6.1.0", "node-html-parser": "^5.3.3", "pathe": "^0.2.0" }, "peerDependencies": { "vite": ">=2.0.0" } }, "sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q=="], "vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], - "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], "vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="], - "vue": ["vue@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", "@vue/runtime-dom": "3.5.28", "@vue/server-renderer": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg=="], + "vue": ["vue@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", "@vue/runtime-dom": "3.5.39", "@vue/server-renderer": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA=="], "walk-up-path": ["walk-up-path@3.0.1", "", {}, "sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA=="], "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "workerd": ["workerd@1.20260617.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260617.1", "@cloudflare/workerd-darwin-arm64": "1.20260617.1", "@cloudflare/workerd-linux-64": "1.20260617.1", "@cloudflare/workerd-linux-arm64": "1.20260617.1", "@cloudflare/workerd-windows-64": "1.20260617.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew=="], + "workerd": ["workerd@1.20260625.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260625.1", "@cloudflare/workerd-darwin-arm64": "1.20260625.1", "@cloudflare/workerd-linux-64": "1.20260625.1", "@cloudflare/workerd-linux-arm64": "1.20260625.1", "@cloudflare/workerd-windows-64": "1.20260625.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA=="], - "wrangler": ["wrangler@4.103.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260617.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260617.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260617.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw=="], + "wrangler": ["wrangler@4.105.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260625.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260625.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260625.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -1720,7 +1761,7 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], @@ -1738,21 +1779,19 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "@npmcli/config/ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], - "@npmcli/config/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/config/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@npmcli/git/ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], "@npmcli/git/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@npmcli/git/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/git/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@npmcli/git/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], @@ -1762,45 +1801,37 @@ "@npmcli/package-json/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "@npmcli/package-json/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/package-json/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/concat-stream/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + "@vitejs/plugin-vue/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - "balanced-match/jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "cmake-js/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], - "bun-types/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + "cmake-js/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "cmake-js/fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], - - "cmake-js/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "cmake-js/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "cmake-js/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], "copy-anything/is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], @@ -1808,31 +1839,31 @@ "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], - "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "html-minifier-terser/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "node-abi/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "normalize-package-data/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "normalize-package-data/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "npm-install-checks/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm-install-checks/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm-package-arg/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "npm-pick-manifest/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "npm-pick-manifest/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1844,11 +1875,9 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], - "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "unified-engine/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "unified-engine/@types/node": ["@types/node@22.20.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], "unified-engine/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -1872,7 +1901,7 @@ "@npmcli/map-workspaces/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "@npmcli/package-json/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1880,15 +1909,7 @@ "@npmcli/promise-spawn/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - - "@types/concat-stream/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "balanced-match/jackspeak/@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], - - "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@vitejs/plugin-vue/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "cmake-js/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -1898,83 +1919,75 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], - - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], - - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], - - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], - - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + "unified-engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + "unified-engine/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + "unified-engine/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + "vitepress/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + "@npmcli/map-workspaces/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + "@npmcli/package-json/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + "@npmcli/package-json/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], - "unified-engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], - "unified-engine/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], - "unified-engine/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], - "vitepress/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], - "vitepress/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], - "@npmcli/map-workspaces/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], - "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], - "@npmcli/package-json/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], - "@npmcli/package-json/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@vitejs/plugin-vue/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], "cmake-js/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1986,7 +1999,7 @@ "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "unified-engine/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "unified-engine/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "unified-engine/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -2036,8 +2049,6 @@ "vitepress/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - "vitepress/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "@npmcli/package-json/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "cmake-js/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/demo/boy/package.json b/demo/boy/package.json index bb4e1a4bd..5bed6ebb6 100644 --- a/demo/boy/package.json +++ b/demo/boy/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "esbuild": "^0.28.1", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" } diff --git a/demo/web/package.json b/demo/web/package.json index 4e144418b..bd8d36adf 100644 --- a/demo/web/package.json +++ b/demo/web/package.json @@ -26,7 +26,7 @@ "solid-element": "^1.9.1", "solid-js": "^1.9.13", "tailwindcss": "^4.1.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" } diff --git a/infra/apt/bun.lock b/infra/apt/bun.lock index eef04294f..e0f842a51 100644 --- a/infra/apt/bun.lock +++ b/infra/apt/bun.lock @@ -5,7 +5,7 @@ "name": "apt-moq-dev", "devDependencies": { "@cloudflare/workers-types": "^4", - "typescript": "^6", + "typescript": "7.0.1-rc", "wrangler": "^4", }, }, @@ -149,6 +149,46 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.1-rc", "", { "os": "aix", "cpu": "ppc64" }, "sha512-oqq2ZfEJ7BQuufcC3QBQndZLPNyamYNHLao8lKRBeeSkZKypBqxPSgkzrcFZtbYcIaBvpiyUnQP9MT7DEYHWbw=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.1-rc", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Slc0yTftT2F/uGDmtPst8ijydneL6uZaLEyr2UjahxZpbhTjHFBJ5agXtVz/TL4A+ldxzjzj+E8QtLZlh/5mXw=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.1-rc", "", { "os": "darwin", "cpu": "x64" }, "sha512-h68iFW/LbA1/BsGgSRGFw981/3s1f/rY27YrmeZNuN+ly7dI+fiDduwT9ZT9866x2onoKNRq7PTyxSKyKDzfAQ=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.1-rc", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DE+ppd8Ix2c6OMuRkKY4PJ4hngMGJ9M95OQUP17p9xL/1IKXof7npIeuusMN/bgL5o5JzMfSGh+N+5scTYRg0Q=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.1-rc", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ST1ozHMw0u+CLOnWkcTyWDMV4Qn9osZ6fd1V1lnKDM1t0hZIp81mdGpdHxyHJjd7jdGrb6Gb/QXcZ1uqZ0t5zw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.1-rc", "", { "os": "linux", "cpu": "arm" }, "sha512-gHmHwT5Naq5CKM8g9bbaGeEpnwQEvWCLn3fwP4K2m61VQdDKkPk0Dhab/OoZ4LV2SrMddmclYXTzpyg23YGt5g=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.1-rc", "", { "os": "linux", "cpu": "arm64" }, "sha512-N46pRihK3t5zD5MUtTQcdmQUqr1WI4U2nxno1gLwOtRSsB4krFkRjPHcQNG7h2DtRkX64rQiReX6WKwg2wprMA=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-G17Sao312rgiPBTh2F4nOpLpa3CcnBSaNhqNghZk2LNhnsp1RaMO5HMq2me21gqu9xLpc6CIgHtOzU6JBgNlfg=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-0FQspOb5UsQ4tQKvWgUO3pS9OIWkP7/8dPRWq+CRazJUeQZ4LBjtYK52jg5iIOrvItrVl2CwvRtrU3/9OihwJg=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.1-rc", "", { "os": "linux", "cpu": "ppc64" }, "sha512-SUmwfVBEv6A2Ld0eWfcvW0FqrgemfQL8jFGOmV1qYxsDqumjE5DekHXqbstgmbE4SHr4rrjHjvmuGCY+kTH/vw=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-rxeqnNnGiYzv/LlPHi/3+4p0ooR1cNJLjRIHXKovtiVmxXGJq6gtw8VSpbHuWPekyFMXgIAoLCZN0SQ51rAALQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.1-rc", "", { "os": "linux", "cpu": "s390x" }, "sha512-RYWCHCiPypxajdRHM2CNK/eM22e4Ex5TTjV2pXf7PTtBowGr0xX8i8kIMknyZS0LX2QfleYHouaoMVsFDSle3g=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.1-rc", "", { "os": "linux", "cpu": "x64" }, "sha512-PfLJSu0JzroDkqw2m4nqflPEcn8yev0m/vHFQlY9EzHorzjR6QG0wL8AJHvnD1e6h1s76AZngJ5u+z1K/D/HKw=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.1-rc", "", { "os": "none", "cpu": "arm64" }, "sha512-FfbPxH3dTfp8yVIaNM7bdWTixXuyxpzoemluqcqMROSIz+ImpCG3Q9HO9Ptzp9/giv+P9YYEnCMSXh61migj2w=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.1-rc", "", { "os": "none", "cpu": "x64" }, "sha512-FzdTfSzhRYb6hlav6K3cI5RVgcvCTvNAu/vc+t7B6AmZkThQ+t/1ntnvT5fnHmY1Az2RIBw7/b+qtCEG61HJTQ=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.1-rc", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-PQGhlxfNig+0YQ9Wwzd0USPBkt6w/ZqkBQWsU7G/0JkTzunJel+jSWwhKw4947pak/m7hGSeYiI04xReDLGZww=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.1-rc", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WJ7NYgO2mHmLUkI/tZ+hl8lFd26QPJqO8ONOHNuYbdsybLvSB6B6sep222JIVrOfPRDGvFinbGGB+l3m1FWRWA=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.1-rc", "", { "os": "sunos", "cpu": "x64" }, "sha512-UYjDeUxd765V9qcwlUPk4pEXyL0i3G76CJm9baK4i99u1pGO1psf3nXDw4MMmElVOPvGbZag99ZR/O59E2OX6w=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.1-rc", "", { "os": "win32", "cpu": "arm64" }, "sha512-KzXzFSXZOm7zvEt2Aw0MsB2LbTL88znAiVqTDNAOHdlEb7brgmUQh/X2wM/8Be+N0fjEqWKl65cBKNwpWEbJiw=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.1-rc", "", { "os": "win32", "cpu": "x64" }, "sha512-98R3+OqDr/r0/PLWEoXu88AE0lGVLNd335Ew8ONgzK1JWkNs4ou/5BGt3Or1ij4iXjH+c7PRL+jFjCbtWze+EA=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -185,7 +225,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@7.0.1-rc", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.1-rc", "@typescript/typescript-darwin-arm64": "7.0.1-rc", "@typescript/typescript-darwin-x64": "7.0.1-rc", "@typescript/typescript-freebsd-arm64": "7.0.1-rc", "@typescript/typescript-freebsd-x64": "7.0.1-rc", "@typescript/typescript-linux-arm": "7.0.1-rc", "@typescript/typescript-linux-arm64": "7.0.1-rc", "@typescript/typescript-linux-loong64": "7.0.1-rc", "@typescript/typescript-linux-mips64el": "7.0.1-rc", "@typescript/typescript-linux-ppc64": "7.0.1-rc", "@typescript/typescript-linux-riscv64": "7.0.1-rc", "@typescript/typescript-linux-s390x": "7.0.1-rc", "@typescript/typescript-linux-x64": "7.0.1-rc", "@typescript/typescript-netbsd-arm64": "7.0.1-rc", "@typescript/typescript-netbsd-x64": "7.0.1-rc", "@typescript/typescript-openbsd-arm64": "7.0.1-rc", "@typescript/typescript-openbsd-x64": "7.0.1-rc", "@typescript/typescript-sunos-x64": "7.0.1-rc", "@typescript/typescript-win32-arm64": "7.0.1-rc", "@typescript/typescript-win32-x64": "7.0.1-rc" }, "bin": { "tsc": "bin/tsc" } }, "sha512-drEP77wK7CCDlPfXZH4e008UUQOsw1DFmHmZOZjuNA+yoDLLnSNMZRXi90NbV/1LVo7SbNLq1bs3jjvk49TEqQ=="], "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], diff --git a/infra/apt/package.json b/infra/apt/package.json index d83e56908..8bbd60b22 100644 --- a/infra/apt/package.json +++ b/infra/apt/package.json @@ -6,7 +6,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4", - "typescript": "^6", + "typescript": "7.0.1-rc", "wrangler": "^4" } } diff --git a/infra/rpm/bun.lock b/infra/rpm/bun.lock index 0341e56b0..298277f85 100644 --- a/infra/rpm/bun.lock +++ b/infra/rpm/bun.lock @@ -5,7 +5,7 @@ "name": "rpm-moq-dev", "devDependencies": { "@cloudflare/workers-types": "^4", - "typescript": "^6", + "typescript": "7.0.1-rc", "wrangler": "^4", }, }, @@ -149,6 +149,46 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.1-rc", "", { "os": "aix", "cpu": "ppc64" }, "sha512-oqq2ZfEJ7BQuufcC3QBQndZLPNyamYNHLao8lKRBeeSkZKypBqxPSgkzrcFZtbYcIaBvpiyUnQP9MT7DEYHWbw=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.1-rc", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Slc0yTftT2F/uGDmtPst8ijydneL6uZaLEyr2UjahxZpbhTjHFBJ5agXtVz/TL4A+ldxzjzj+E8QtLZlh/5mXw=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.1-rc", "", { "os": "darwin", "cpu": "x64" }, "sha512-h68iFW/LbA1/BsGgSRGFw981/3s1f/rY27YrmeZNuN+ly7dI+fiDduwT9ZT9866x2onoKNRq7PTyxSKyKDzfAQ=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.1-rc", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-DE+ppd8Ix2c6OMuRkKY4PJ4hngMGJ9M95OQUP17p9xL/1IKXof7npIeuusMN/bgL5o5JzMfSGh+N+5scTYRg0Q=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.1-rc", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ST1ozHMw0u+CLOnWkcTyWDMV4Qn9osZ6fd1V1lnKDM1t0hZIp81mdGpdHxyHJjd7jdGrb6Gb/QXcZ1uqZ0t5zw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.1-rc", "", { "os": "linux", "cpu": "arm" }, "sha512-gHmHwT5Naq5CKM8g9bbaGeEpnwQEvWCLn3fwP4K2m61VQdDKkPk0Dhab/OoZ4LV2SrMddmclYXTzpyg23YGt5g=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.1-rc", "", { "os": "linux", "cpu": "arm64" }, "sha512-N46pRihK3t5zD5MUtTQcdmQUqr1WI4U2nxno1gLwOtRSsB4krFkRjPHcQNG7h2DtRkX64rQiReX6WKwg2wprMA=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-G17Sao312rgiPBTh2F4nOpLpa3CcnBSaNhqNghZk2LNhnsp1RaMO5HMq2me21gqu9xLpc6CIgHtOzU6JBgNlfg=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-0FQspOb5UsQ4tQKvWgUO3pS9OIWkP7/8dPRWq+CRazJUeQZ4LBjtYK52jg5iIOrvItrVl2CwvRtrU3/9OihwJg=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.1-rc", "", { "os": "linux", "cpu": "ppc64" }, "sha512-SUmwfVBEv6A2Ld0eWfcvW0FqrgemfQL8jFGOmV1qYxsDqumjE5DekHXqbstgmbE4SHr4rrjHjvmuGCY+kTH/vw=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.1-rc", "", { "os": "linux", "cpu": "none" }, "sha512-rxeqnNnGiYzv/LlPHi/3+4p0ooR1cNJLjRIHXKovtiVmxXGJq6gtw8VSpbHuWPekyFMXgIAoLCZN0SQ51rAALQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.1-rc", "", { "os": "linux", "cpu": "s390x" }, "sha512-RYWCHCiPypxajdRHM2CNK/eM22e4Ex5TTjV2pXf7PTtBowGr0xX8i8kIMknyZS0LX2QfleYHouaoMVsFDSle3g=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.1-rc", "", { "os": "linux", "cpu": "x64" }, "sha512-PfLJSu0JzroDkqw2m4nqflPEcn8yev0m/vHFQlY9EzHorzjR6QG0wL8AJHvnD1e6h1s76AZngJ5u+z1K/D/HKw=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.1-rc", "", { "os": "none", "cpu": "arm64" }, "sha512-FfbPxH3dTfp8yVIaNM7bdWTixXuyxpzoemluqcqMROSIz+ImpCG3Q9HO9Ptzp9/giv+P9YYEnCMSXh61migj2w=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.1-rc", "", { "os": "none", "cpu": "x64" }, "sha512-FzdTfSzhRYb6hlav6K3cI5RVgcvCTvNAu/vc+t7B6AmZkThQ+t/1ntnvT5fnHmY1Az2RIBw7/b+qtCEG61HJTQ=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.1-rc", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-PQGhlxfNig+0YQ9Wwzd0USPBkt6w/ZqkBQWsU7G/0JkTzunJel+jSWwhKw4947pak/m7hGSeYiI04xReDLGZww=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.1-rc", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WJ7NYgO2mHmLUkI/tZ+hl8lFd26QPJqO8ONOHNuYbdsybLvSB6B6sep222JIVrOfPRDGvFinbGGB+l3m1FWRWA=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.1-rc", "", { "os": "sunos", "cpu": "x64" }, "sha512-UYjDeUxd765V9qcwlUPk4pEXyL0i3G76CJm9baK4i99u1pGO1psf3nXDw4MMmElVOPvGbZag99ZR/O59E2OX6w=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.1-rc", "", { "os": "win32", "cpu": "arm64" }, "sha512-KzXzFSXZOm7zvEt2Aw0MsB2LbTL88znAiVqTDNAOHdlEb7brgmUQh/X2wM/8Be+N0fjEqWKl65cBKNwpWEbJiw=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.1-rc", "", { "os": "win32", "cpu": "x64" }, "sha512-98R3+OqDr/r0/PLWEoXu88AE0lGVLNd335Ew8ONgzK1JWkNs4ou/5BGt3Or1ij4iXjH+c7PRL+jFjCbtWze+EA=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -185,7 +225,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@7.0.1-rc", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.1-rc", "@typescript/typescript-darwin-arm64": "7.0.1-rc", "@typescript/typescript-darwin-x64": "7.0.1-rc", "@typescript/typescript-freebsd-arm64": "7.0.1-rc", "@typescript/typescript-freebsd-x64": "7.0.1-rc", "@typescript/typescript-linux-arm": "7.0.1-rc", "@typescript/typescript-linux-arm64": "7.0.1-rc", "@typescript/typescript-linux-loong64": "7.0.1-rc", "@typescript/typescript-linux-mips64el": "7.0.1-rc", "@typescript/typescript-linux-ppc64": "7.0.1-rc", "@typescript/typescript-linux-riscv64": "7.0.1-rc", "@typescript/typescript-linux-s390x": "7.0.1-rc", "@typescript/typescript-linux-x64": "7.0.1-rc", "@typescript/typescript-netbsd-arm64": "7.0.1-rc", "@typescript/typescript-netbsd-x64": "7.0.1-rc", "@typescript/typescript-openbsd-arm64": "7.0.1-rc", "@typescript/typescript-openbsd-x64": "7.0.1-rc", "@typescript/typescript-sunos-x64": "7.0.1-rc", "@typescript/typescript-win32-arm64": "7.0.1-rc", "@typescript/typescript-win32-x64": "7.0.1-rc" }, "bin": { "tsc": "bin/tsc" } }, "sha512-drEP77wK7CCDlPfXZH4e008UUQOsw1DFmHmZOZjuNA+yoDLLnSNMZRXi90NbV/1LVo7SbNLq1bs3jjvk49TEqQ=="], "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], diff --git a/infra/rpm/package.json b/infra/rpm/package.json index 6065098d2..971b908fa 100644 --- a/infra/rpm/package.json +++ b/infra/rpm/package.json @@ -6,7 +6,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4", - "typescript": "^6", + "typescript": "7.0.1-rc", "wrangler": "^4" } } diff --git a/js/clock/package.json b/js/clock/package.json index 2dc0d8c76..6e8c7b0b0 100644 --- a/js/clock/package.json +++ b/js/clock/package.json @@ -23,6 +23,6 @@ }, "devDependencies": { "@types/node": "^26.0.0", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/flate/package.json b/js/flate/package.json index 0bfec85c6..ed7b5a235 100644 --- a/js/flate/package.json +++ b/js/flate/package.json @@ -23,6 +23,6 @@ "@types/pako": "^2.0.4", "fflate": "^0.8.2", "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/hang/package.json b/js/hang/package.json index 0932dd2ad..36e1cf21c 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -33,6 +33,6 @@ "@typescript/lib-dom": "npm:@types/web@^0.0.350", "fast-glob": "^3.3.3", "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/json/package.json b/js/json/package.json index a02537bef..431200307 100644 --- a/js/json/package.json +++ b/js/json/package.json @@ -26,6 +26,6 @@ "devDependencies": { "@types/bun": "^1.3.14", "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/loc/package.json b/js/loc/package.json index 8ce7fe140..10ab5ec45 100644 --- a/js/loc/package.json +++ b/js/loc/package.json @@ -20,6 +20,6 @@ "devDependencies": { "@types/bun": "^1.3.14", "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/moq-boy/package.json b/js/moq-boy/package.json index a51f05f70..f200211da 100644 --- a/js/moq-boy/package.json +++ b/js/moq-boy/package.json @@ -35,7 +35,7 @@ "esbuild": "^0.28.1", "rimraf": "^6.1.3", "solid-js": "^1.9.13", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" } diff --git a/js/msf/package.json b/js/msf/package.json index b9af21164..c33f6729b 100644 --- a/js/msf/package.json +++ b/js/msf/package.json @@ -20,6 +20,6 @@ }, "devDependencies": { "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/net/package.json b/js/net/package.json index e1fe6f72a..ad7f4901e 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -29,7 +29,7 @@ "@types/node": "^26.0.0", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite-plugin-html": "^3.2.2" } } diff --git a/js/publish/package.json b/js/publish/package.json index 089a549cf..a75213451 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -36,7 +36,7 @@ "@typescript/lib-dom": "npm:@types/web@^0.0.350", "esbuild": "^0.28.1", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16" } } diff --git a/js/signals/package.json b/js/signals/package.json index f69dea5d0..7cf578589 100644 --- a/js/signals/package.json +++ b/js/signals/package.json @@ -19,7 +19,7 @@ "release": "bun ../common/release.ts" }, "devDependencies": { - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "rimraf": "^6.1.3", "@types/bun": "^1.3.11", "@types/react": "^19.2.17", diff --git a/js/signals/src/dom.ts b/js/signals/src/dom.ts index 05121c719..0711c1541 100644 --- a/js/signals/src/dom.ts +++ b/js/signals/src/dom.ts @@ -15,7 +15,7 @@ export type CreateOptions = { id?: string; dataset?: Record; attributes?: Record; -} & Partial>; +} & Partial>; /** Creates an HTML element, applying the given options and appending the given children. */ export function create( diff --git a/js/token/package.json b/js/token/package.json index d69c3d2c7..1e5e8f0b9 100644 --- a/js/token/package.json +++ b/js/token/package.json @@ -28,6 +28,6 @@ "@types/bun": "^1.3.14", "@types/node": "^26.0.0", "rimraf": "^6.1.3", - "typescript": "^6.0.3" + "typescript": "7.0.1-rc" } } diff --git a/js/watch/package.json b/js/watch/package.json index 7408240b8..c38d7d794 100644 --- a/js/watch/package.json +++ b/js/watch/package.json @@ -37,7 +37,7 @@ "@typescript/lib-dom": "npm:@types/web@^0.0.350", "esbuild": "^0.28.1", "rimraf": "^6.1.3", - "typescript": "^6.0.3", + "typescript": "7.0.1-rc", "vite": "^8.0.16" } } From d71655a338a41c56974fb6ecefc0576b53b57e42 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 28 Jun 2026 17:06:07 -0700 Subject: [PATCH 25/34] Fix fMP4 zero-duration samples (#1933) Co-authored-by: OpenAI Codex --- rs/moq-mux/src/container/fmp4/export.rs | 116 +++++++++++++++++++++++- rs/moq-mux/src/container/fmp4/import.rs | 23 ++++- rs/moq-mux/src/container/fmp4/mod.rs | 41 ++++++++- 3 files changed, 172 insertions(+), 8 deletions(-) diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index 081f90006..f72a4e98a 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -9,8 +9,8 @@ use mp4_atom::{DecodeMaybe, Encode}; use crate::Result; use crate::catalog::Stream; use crate::container::ExportSource; -use crate::container::Frame; use crate::container::fmp4::Error; +use crate::container::{Frame, Timestamp}; /// Subscribe to a moq broadcast and produce a single fMP4 / CMAF byte stream. /// @@ -249,7 +249,7 @@ impl Export { let flush_before = should_flush(track, &frame, frag, has_video_track); if flush_before { let frames = std::mem::take(&mut track.buffer); - let fragment = emit_fragment(track, frames)?; + let fragment = emit_fragment(track, frames, Some(&frame))?; // The flushed run is done; the incoming frame opens the next buffer. track.buffer_independent = frame.keyframe; track.buffer.push(frame); @@ -281,7 +281,7 @@ impl Export { if let Some(name) = flushable { let track = self.tracks.get_mut(&name).unwrap(); let frames = std::mem::take(&mut track.buffer); - let fragment = emit_fragment(track, frames)?; + let fragment = emit_fragment(track, frames, None)?; return Poll::Ready(Ok(Some(fragment))); } @@ -556,10 +556,11 @@ fn encode_fragment(track: &mut Fmp4Track, frames: Vec) -> Result { } /// Encode a buffered run and wrap it with the metadata a segmenting consumer needs. -fn emit_fragment(track: &mut Fmp4Track, frames: Vec) -> Result { +fn emit_fragment(track: &mut Fmp4Track, frames: Vec, successor: Option<&Frame>) -> Result { // Audio has no keyframes, so every audio fragment is independent; video is // independent only when its buffer opened on a keyframe (a GOP boundary). let independent = !track.is_video || track.buffer_independent; + let frames = infer_missing_durations(frames, successor, track.default_frame); let duration = fragment_seconds(&frames, track.default_frame); let data = encode_fragment(track, frames)?; Ok(Fragment { @@ -580,7 +581,10 @@ fn fragment_seconds(frames: &[Frame], default_frame: Duration) -> f64 { if frames.is_empty() { return 0.0; } - if frames.iter().all(|f| f.duration.is_some()) { + if frames + .iter() + .all(|f| f.duration.is_some_and(|duration| !duration.is_zero())) + { return frames .iter() .map(|f| Duration::from(f.duration.unwrap())) @@ -597,6 +601,42 @@ fn fragment_seconds(frames: &[Frame], default_frame: Duration) -> f64 { ((max - min) + default_frame).as_secs_f64() } +fn infer_missing_durations(mut frames: Vec, successor: Option<&Frame>, default_frame: Duration) -> Vec { + let infer_from_pts = pts_monotonic(&frames, successor); + let fallback = Timestamp::try_from(default_frame).ok(); + + for i in 0..frames.len() { + if frames[i].duration.is_some_and(|duration| !duration.is_zero()) { + continue; + } + + frames[i].duration = infer_from_pts + .then(|| next_timestamp(&frames, successor, i)) + .flatten() + .and_then(|timestamp| timestamp.checked_sub(frames[i].timestamp).ok()) + .filter(|duration| !duration.is_zero()) + .or(fallback); + } + + frames +} + +fn pts_monotonic(frames: &[Frame], successor: Option<&Frame>) -> bool { + let frames_monotonic = frames.windows(2).all(|pair| pair[1].timestamp >= pair[0].timestamp); + let successor_monotonic = match (frames.last(), successor) { + (Some(last), Some(successor)) => successor.timestamp >= last.timestamp, + _ => true, + }; + frames_monotonic && successor_monotonic +} + +fn next_timestamp(frames: &[Frame], successor: Option<&Frame>, index: usize) -> Option { + frames + .get(index + 1) + .map(|next| next.timestamp) + .or_else(|| successor.map(|next| next.timestamp)) +} + fn catalog_timescale_video(config: &VideoConfig) -> u64 { match &config.container { Container::Cmaf { init, .. } => { @@ -623,3 +663,69 @@ fn parse_timescale_from_init(init: &[u8]) -> Result { } Err(Error::NoMoov.into()) } + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use super::*; + + fn ts(micros: u64) -> Timestamp { + Timestamp::from_micros(micros).unwrap() + } + + fn frame(timestamp_us: u64, duration_us: Option) -> Frame { + Frame { + timestamp: ts(timestamp_us), + duration: duration_us.map(ts), + payload: Bytes::from_static(&[0xDE, 0xAD]), + keyframe: false, + } + } + + #[test] + fn infer_missing_durations_uses_default_for_trailing_sample() { + let frames = infer_missing_durations( + vec![frame(0, Some(0)), frame(41_667, None), frame(83_334, None)], + None, + Duration::from_millis(33), + ); + + assert_eq!(frames[0].duration, Some(ts(41_667))); + assert_eq!(frames[1].duration, Some(ts(41_667))); + assert_eq!(frames[2].duration, Some(ts(33_000))); + assert_eq!(fragment_seconds(&frames, Duration::from_millis(33)), 0.116334); + } + + #[test] + fn infer_missing_duration_uses_default_for_single_frame() { + let frames = infer_missing_durations(vec![frame(83_333, Some(0))], None, Duration::from_millis(40)); + + assert_eq!(frames[0].duration, Some(ts(40_000))); + assert_eq!(fragment_seconds(&frames, Duration::from_millis(40)), 0.04); + } + + #[test] + fn infer_trailing_duration_from_successor_frame() { + let successor = frame(83_334, None); + let frames = infer_missing_durations(vec![frame(41_667, None)], Some(&successor), Duration::from_millis(33)); + + assert_eq!(frames[0].duration, Some(ts(41_667))); + assert_eq!(fragment_seconds(&frames, Duration::from_millis(33)), 0.041667); + } + + #[test] + fn infer_missing_durations_avoids_non_monotonic_pts() { + let successor = frame(66_000, None); + let frames = infer_missing_durations( + vec![frame(0, None), frame(99_000, None), frame(33_000, None)], + Some(&successor), + Duration::from_millis(33), + ); + + assert_eq!(frames[0].duration, Some(ts(33_000))); + assert_eq!(frames[1].duration, Some(ts(33_000))); + assert_eq!(frames[2].duration, Some(ts(33_000))); + assert_eq!(fragment_seconds(&frames, Duration::from_millis(33)), 0.099); + } +} diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 3d69cd9e6..4f96bcc6e 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -443,6 +443,8 @@ impl Import { let mut min_timestamp = None; let mut max_timestamp = None; let mut contains_keyframe = false; + let total_samples: usize = traf.trun.iter().map(|t| t.entries.len()).sum(); + let mut sample_index = 0usize; for trun in &traf.trun { let tfhd = &traf.tfhd; @@ -472,11 +474,17 @@ impl Import { .unwrap_or(tfhd.default_sample_flags.unwrap_or(default_sample_flags)); let duration = entry .duration - .unwrap_or(tfhd.default_sample_duration.unwrap_or(default_sample_duration)); + .or(tfhd.default_sample_duration) + .or(Some(default_sample_duration)) + .filter(|duration| *duration != 0); let size = entry .size .unwrap_or(tfhd.default_sample_size.unwrap_or(default_sample_size)) as usize; + if duration.is_none() && sample_index + 1 != total_samples { + return Err(Error::MissingSampleDuration.into()); + } + // Checked: a negative composition offset must not wrap into a huge u64 PTS. let pts = dts .checked_add_signed(entry.cts.unwrap_or_default() as i64) @@ -517,8 +525,11 @@ impl Import { track.last_timestamp = Some(timestamp); - dts = dts.checked_add(duration as u64).ok_or(Error::PtsOverflow)?; + if let Some(duration) = duration { + dts = dts.checked_add(duration as u64).ok_or(Error::PtsOverflow)?; + } offset = sample_end; + sample_index += 1; } } @@ -555,9 +566,17 @@ impl Import { // and ensuring trun.data_offset is Some(...) reserves 4 bytes per trun. for traf_mut in &mut adjusted_moof.traf { traf_mut.tfhd.base_data_offset = None; + if traf_mut.tfhd.default_sample_duration == Some(0) { + traf_mut.tfhd.default_sample_duration = None; + } for trun_mut in &mut traf_mut.trun { // Reserve the data_offset field; the real value is filled in below. trun_mut.data_offset = Some(0); + for entry in &mut trun_mut.entries { + if entry.duration == Some(0) { + entry.duration = None; + } + } } } diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 000a42e1c..5be7927bf 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -262,7 +262,7 @@ pub(crate) fn decode(data: Bytes, timescale: u64) -> Result> { // Carry the sample-duration through at the track's scale when present, so // the jitter buffer can use it and an exporter can write it back. - let sample_duration = entry.duration.or(default_duration); + let sample_duration = entry.duration.or(default_duration).filter(|d| *d != 0); // The last sample needs no duration (nothing follows it to time), but any // earlier sample without one makes the rest of the fragment's DTS ambiguous. @@ -694,4 +694,43 @@ mod tests { assert_eq!(frames.len(), 1); assert_eq!(frames[0].duration, None); } + + #[test] + fn decode_zero_duration_reports_none() { + use mp4_atom::Encode; + + let timescale = 24_000; + let moof = mp4_atom::Moof { + mfhd: mp4_atom::Mfhd { sequence_number: 0 }, + traf: vec![mp4_atom::Traf { + tfhd: mp4_atom::Tfhd { + track_id: 1, + default_sample_duration: Some(0), + default_sample_size: Some(2), + ..Default::default() + }, + tfdt: Some(mp4_atom::Tfdt { + base_media_decode_time: 2_000, + }), + trun: vec![mp4_atom::Trun { + data_offset: Some(0), + entries: vec![mp4_atom::TrunEntry { + size: None, + duration: None, + ..Default::default() + }], + }], + ..Default::default() + }], + }; + + let mut buf = Vec::new(); + moof.encode(&mut buf).unwrap(); + mp4_atom::Mdat { data: vec![0xDE, 0xAD] }.encode(&mut buf).unwrap(); + + let frames = decode(Bytes::from(buf), timescale).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].timestamp.as_micros(), 83_333); + assert_eq!(frames[0].duration, None); + } } From 4c60c52becd6c1d4055c0b3af1383a4ce92f40f9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 29 Jun 2026 11:22:21 -0700 Subject: [PATCH 26/34] [codex] Route HLS CLI import through moq-hls (#1939) Co-authored-by: Claude Opus 4.8 --- .taplo.toml | 2 +- Cargo.lock | 12 +- Cargo.toml | 1 + demo/pub/justfile | 4 +- doc/bin/cli.md | 19 +- doc/index.md | 4 +- doc/lib/index.md | 2 +- doc/lib/rs/crate/hang.md | 4 +- doc/lib/rs/crate/index.md | 2 +- doc/lib/rs/crate/moq-mux.md | 20 +- doc/lib/rs/index.md | 2 +- js/CLAUDE.md | 5 + js/watch/README.md | 2 +- rs/CLAUDE.md | 10 +- rs/hang/README.md | 2 +- rs/moq-cli/Cargo.toml | 1 + rs/moq-cli/README.md | 1 - rs/moq-cli/src/main.rs | 187 ++++++++ rs/moq-cli/src/publish.rs | 1 + rs/moq-hls/Cargo.toml | 29 +- rs/moq-hls/bin/moq-hls.rs | 213 --------- rs/moq-mux/README.md | 2 +- rs/moq-mux/src/container/hls/import.rs | 616 ------------------------- rs/moq-rtmp/README.md | 4 +- rs/moq-rtmp/bin/moq-rtmp.rs | 2 +- rs/moq-rtmp/src/lib.rs | 4 +- rs/moq-srt/README.md | 4 +- rs/moq-srt/bin/moq-srt.rs | 2 +- rs/moq-srt/src/lib.rs | 2 +- 29 files changed, 252 insertions(+), 907 deletions(-) delete mode 100644 rs/moq-hls/bin/moq-hls.rs delete mode 100644 rs/moq-mux/src/container/hls/import.rs diff --git a/.taplo.toml b/.taplo.toml index 2d69dedde..f248b5f2c 100644 --- a/.taplo.toml +++ b/.taplo.toml @@ -1,5 +1,5 @@ # Skip vendored / build trees so `taplo format` doesn't recurse into them. -exclude = ["**/node_modules/**", "**/target/**", "**/dist/**", "**/.venv/**"] +exclude = ["**/node_modules/**", "**/target/**", "**/dist/**", "**/.venv/**", "**/.direnv/**"] # Match the existing repo style (4-space indent on Cargo.toml, lines up # to ~120 chars) so the initial format pass stays minimal. diff --git a/Cargo.lock b/Cargo.lock index e990a342e..353307533 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,9 +171,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arc-swap" @@ -4101,6 +4101,7 @@ dependencies = [ "clap", "hang", "humantime", + "moq-hls", "moq-mux", "moq-native", "rustls", @@ -4163,22 +4164,15 @@ version = "0.0.1" dependencies = [ "anyhow", "axum", - "axum-server", "bytes", - "clap", "hang", - "humantime", "kio", "m3u8-rs", "moq-mux", - "moq-native", "moq-net", "reqwest 0.12.28", - "rustls", - "sd-notify", "thiserror 2.0.18", "tokio", - "tower-http", "tracing", "url", ] diff --git a/Cargo.toml b/Cargo.toml index 595615262..974c2977b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ hang = { version = "0.19", path = "rs/hang" } kio = { version = "0.4", path = "rs/kio" } moq-audio = { version = "0.0.5", path = "rs/moq-audio" } moq-flate = { version = "0.1.0", path = "rs/moq-flate" } +moq-hls = { version = "0.0.1", path = "rs/moq-hls", default-features = false } moq-json = { version = "0.1.0", path = "rs/moq-json" } moq-loc = { version = "0.1", path = "rs/moq-loc" } moq-msf = { version = "0.2", path = "rs/moq-msf" } diff --git a/demo/pub/justfile b/demo/pub/justfile index 95faac6d7..0cc6aed29 100644 --- a/demo/pub/justfile +++ b/demo/pub/justfile @@ -141,8 +141,8 @@ hls name relay="http://localhost:4443": } trap cleanup SIGINT SIGTERM EXIT - echo ">>> Running with --passthrough flag" - cargo run --bin moq-cli -- publish --url "{{ relay }}" --name "{{ name }}.hang" hls --playlist "$OUT_DIR/master.m3u8" --passthrough + echo ">>> Importing HLS via moq-cli" + cargo run --bin moq-cli -- hls --url "{{ relay }}" import --broadcast "{{ name }}.hang" --playlist "$OUT_DIR/master.m3u8" EXIT_CODE=$? cleanup diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 8713dd592..91ce37a6d 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -141,7 +141,6 @@ Publish (read from stdin unless noted): - `fmp4` - fragmented MP4 / CMAF - `ts` - MPEG-TS (H.264 / H.265 video; AAC, MP2, AC-3, or E-AC-3 audio) - `flv` - FLV / RTMP (H.264 video, AAC audio) -- `hls --playlist ` - HLS playlist ingest - `capture` - capture local devices directly (camera H.264 + microphone Opus; requires the `capture` build feature; does not read stdin) Subscribe (`--format`): @@ -159,6 +158,22 @@ discovery. When omitted, it's auto-detected from the broadcast name suffix - `hangz` - the DEFLATE-compressed `catalog.json.z` catalog (opt-in; shares the `.hang` suffix and is never auto-detected) - `msf` - the MSF `catalog` track +### HLS / LL-HLS + +Import an HLS master/media playlist into a MoQ broadcast: + +```bash +moq-cli hls --url https://relay.example.com/anon import \ + --broadcast my-stream.hang \ + --playlist https://example.com/live/master.m3u8 +``` + +Serve MoQ broadcasts as HLS / LL-HLS over HTTP: + +```bash +moq-cli hls --url https://relay.example.com/anon export --listen '[::]:8089' +``` + ### MPEG-TS Ingest an MPEG-TS stream from FFmpeg and play one back out: @@ -187,7 +202,7 @@ Elementary streams the CLI does not decode (SCTE-35 cues, teletext, DVB subtitles, private data, ...) are carried verbatim too, one MoQ track per PID, described in the catalog `mpegts` section. They survive `publish ts | relay | subscribe --format ts` end-to-end with their original PIDs, PMT descriptors, and -PES stream_ids, so a contribution feed keeps its ancillary streams. The relay +PES stream\_ids, so a contribution feed keeps its ancillary streams. The relay forwards them transparently and never parses the payload. ### FLV diff --git a/doc/index.md b/doc/index.md index c9cfdbde9..27da02ccc 100644 --- a/doc/index.md +++ b/doc/index.md @@ -99,7 +99,7 @@ There are a bunch of MoQ binaries and plugins. Some highlights: - [moq-relay](/bin/relay/) - A server connecting publishers to subscribers, able to form a [self-hosted CDN cluster](/bin/relay/cluster). -- [moq-cli](/bin/cli) - A CLI that can import and publish MoQ broadcasts from a variety of formats (fMP4, HLS, etc), including via ffmpeg. +- [moq-cli](/bin/cli) - A CLI that can import and publish MoQ broadcasts from a variety of formats (fMP4, HLS, MPEG-TS, FLV, etc), including via ffmpeg. - [obs](/bin/obs) - An OBS plugin, able to publish a MoQ broadcast and/or use MoQ broadcasts as sources. - [gstreamer](/bin/gstreamer) - A gstreamer plugin, split into a source and a sink. - [web](/bin/web) - A web component you can slap on your website to watch and publish MoQ broadcasts. @@ -112,7 +112,7 @@ Integrate MoQ into your application without fear. Focused on [native](/lib/rs/en Some highlights: - [moq-net](/lib/rs/crate/moq-net) - Real-time pub/sub with built-in caching, fan-out, and prioritization. -- [moq-mux](/lib/rs/crate/moq-mux) - Media muxers/demuxers for fMP4, CMAF, and HLS import. +- [moq-mux](/lib/rs/crate/moq-mux) - Media muxers/demuxers for fMP4, CMAF, MPEG-TS, and FLV. - [libmoq](/lib/rs/crate/libmoq) - C bindings for the above, no finagling Rust into your build system. - [web-transport](/lib/rs/crate/web-transport) - A suite of crates required to get QUIC access in the browser, plus some polyfills. - [...and more](/lib/rs/) diff --git a/doc/lib/index.md b/doc/lib/index.md index a17929eaf..6e823ceab 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -15,7 +15,7 @@ The reference implementation. Used by every server-side tool and by the FFI core - [`moq-net`](/lib/rs/crate/moq-net) - Real-time pub/sub - [`hang`](/lib/rs/crate/hang) - Media catalog and container -- [`moq-mux`](/lib/rs/crate/moq-mux) - fMP4/CMAF/HLS import +- [`moq-mux`](/lib/rs/crate/moq-mux) - fMP4/CMAF, MPEG-TS, and FLV import/export - [`moq-native`](/lib/rs/crate/moq-native) - QUIC endpoint helpers - [...and more](/lib/rs/) diff --git a/doc/lib/rs/crate/hang.md b/doc/lib/rs/crate/hang.md index cd266bc01..b783a4047 100644 --- a/doc/lib/rs/crate/hang.md +++ b/doc/lib/rs/crate/hang.md @@ -94,7 +94,7 @@ Each frame in `hang` consists of a timestamp and codec bitstream payload. See th ## CMAF Import -For importing fMP4/CMAF/HLS files, see the [moq-mux](/lib/rs/crate/moq-mux) crate. +For importing fMP4/CMAF files, see the [moq-mux](/lib/rs/crate/moq-mux) crate. For HLS, see [moq-hls](https://github.com/moq-dev/moq/tree/main/rs/moq-hls). ## Grouping @@ -153,7 +153,7 @@ Key types: - `Catalog` - Track metadata - `VideoConfig` / `AudioConfig` - Track configuration - `Frame` - Timestamp + codec bitstream -- [moq-mux](/lib/rs/crate/moq-mux) - CMAF/fMP4/HLS import +- [moq-mux](/lib/rs/crate/moq-mux) - CMAF/fMP4 import ## Protocol Specification diff --git a/doc/lib/rs/crate/index.md b/doc/lib/rs/crate/index.md index acf39fff0..6ee81f317 100644 --- a/doc/lib/rs/crate/index.md +++ b/doc/lib/rs/crate/index.md @@ -14,5 +14,5 @@ Rust crates providing the MoQ protocol implementation and related tooling. | [moq-token](./moq-token) | JWT authentication library | | [hang](./hang) | Media encoding/streaming (catalog, container) | | [web-transport](./web-transport) | WebTransport protocol support | -| [moq-mux](./moq-mux) | Media muxers/demuxers (fMP4, CMAF, HLS) | +| [moq-mux](./moq-mux) | Media muxers/demuxers (fMP4, CMAF, MPEG-TS, FLV) | | [libmoq](./libmoq) | C FFI bindings | diff --git a/doc/lib/rs/crate/moq-mux.md b/doc/lib/rs/crate/moq-mux.md index 501c724d1..0c21e17f8 100644 --- a/doc/lib/rs/crate/moq-mux.md +++ b/doc/lib/rs/crate/moq-mux.md @@ -19,7 +19,6 @@ Media muxers and demuxers for converting existing media formats into MoQ broadca - **MPEG-TS** - Transport stream (import and export) - **Matroska / WebM** - EBML container (import and export) - **FLV** - Flash Video / RTMP container (H.264 + AAC; import and export) -- **HLS** - HTTP Live Streaming playlists - **Annex B** - H.264/H.265 raw NAL unit streams This crate is designed for ingesting existing content into the MoQ ecosystem, converting from traditional formats into [hang](/lib/rs/crate/hang) broadcasts. @@ -33,27 +32,18 @@ Add to your `Cargo.toml`: moq-mux = "0.1" ``` -### Feature Flags - -| Feature | Default | Description | -|---------|---------|-------------| -| `mp4` | ✓ | fMP4/CMAF support | -| `h264` | ✓ | H.264 codec support | -| `h265` | ✓ | H.265 codec support | -| `hls` | ✓ | HLS playlist import | - ## Quick Start -### Import fMP4 / HLS +### Import fMP4 -See the [moq-cli source](https://github.com/moq-dev/moq/tree/main/rs/moq-cli) for real-world usage of `moq-mux` for importing fMP4 and HLS streams. +See the [moq-cli source](https://github.com/moq-dev/moq/tree/main/rs/moq-cli) for real-world usage of `moq-mux` for importing fMP4 streams. Use [moq-hls](https://github.com/moq-dev/moq/tree/main/rs/moq-hls) for HLS import and export. ## Supported Codecs **Video:** -- H.264 (AVC) - requires `h264` feature -- H.265 (HEVC) - requires `h265` feature +- H.264 (AVC) +- H.265 (HEVC) - AV1 - VP8 - VP9 @@ -69,7 +59,6 @@ See the [moq-cli source](https://github.com/moq-dev/moq/tree/main/rs/moq-cli) fo ## Use Cases - **Ingest existing content** - Convert VOD files to MoQ broadcasts -- **HLS bridge** - Re-publish HLS streams over MoQ for lower latency - **Testing** - Use sample files for development and testing - **Migration** - Transition from traditional streaming to MoQ @@ -85,7 +74,6 @@ Key types: - `Fmp4` - fMP4/CMAF importer - `Fmp4Config` - Configuration for fMP4 import -- `Hls` - HLS playlist importer - `Decoder` - Codec-specific decoders (AAC, Opus, AVC, HEVC) ## CLI Tool diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 3d10f957a..8a0db2393 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -50,7 +50,7 @@ Media muxers and demuxers for importing existing formats into MoQ. **Features:** - fMP4/CMAF import -- HLS playlist import +- MPEG-TS and FLV import/export - H.264/H.265 Annex B parsing - AAC and Opus codec support diff --git a/js/CLAUDE.md b/js/CLAUDE.md index 0ba19177c..e3d1851f4 100644 --- a/js/CLAUDE.md +++ b/js/CLAUDE.md @@ -7,23 +7,28 @@ Scopes the `/js` TypeScript/JavaScript workspace. Universal rules (writing style Bun workspaces; members listed in the repo-root `package.json` (not in `js/`). Deps hoist to the repo root `node_modules`, not into `js/`. Run recipes via `just js ` (see `js/justfile`). Packages, grouped by role (each mirrors its `rs/` counterpart where one exists), roughly in dependency order: **Foundation** + - `@moq/signals` (`signals/`): reactive core. `Signal`, `Computed`, `Effect`, plus framework adapters at subpaths `./solid`, `./react`, `./dom`. No deps on other workspace packages. Everything below uses it. **Transport / protocol** + - `@moq/net` (`net/`): browser networking. Connect to a relay, then publish/consume broadcasts/tracks/groups/frames over WebTransport (WebSocket fallback). Negotiates `moq-lite` (`lite/`) or IETF `moq-transport` (`ietf/`). Mirror of `rs/moq-net`. Optional `zod` peer dep for `./zod` JSON-frame helpers. **Container / catalog formats** + - `@moq/loc` (`loc/`): Low Overhead Container frame encoding. Thin layer on `@moq/net`. - `@moq/json` (`json/`): snapshot/delta JSON over a track via RFC 7396 merge-patch. Exposes the base `Producer`/`Consumer` that `@moq/hang`'s catalog extends. DEFLATE via `@moq/flate`. - `@moq/flate` (`flate/`): group-scoped DEFLATE primitive (only deps on `pako`). `Encoder`/`Decoder` turn a stream of payloads into self-delimited sync-flushed frames sharing one window; wire-interoperable with the Rust `moq-flate` crate. Used by `@moq/json`. - `@moq/msf` (`msf/`): MOQT Streaming Format catalog types (zod schemas). **Media** + - `@moq/hang` (`hang/`): WebCodecs media layer. Subpaths `./catalog`, `./container`, `./util`. Mirror of `rs/hang`. Catalog is a JSON track describing other tracks; container frames are timestamp + codec bitstream (CMAF under `container/cmaf`). - `@moq/watch` (`watch/`): subscribe + decode + render, with optional UI. Subpaths `.`, `./element`, `./ui`, `./support`. - `@moq/publish` (`publish/`): capture + encode + publish, with optional UI. Same subpath shape as watch. **Apps / examples** + - `@moq/boy` (`moq-boy/`): MoQ Boy web viewer. The only package using `.tsx`/Solid. - `@moq/clock` (`clock/`): private native example (publish/subscribe a clock). - `@moq/token` (`token/`): JWT generation/validation (`jose`); also ships a `moq-token` bin. Mirror of `rs/moq-token`. diff --git a/js/watch/README.md b/js/watch/README.md index d9f24fd8d..4c0db7123 100644 --- a/js/watch/README.md +++ b/js/watch/README.md @@ -70,7 +70,7 @@ The simplest way to watch a stream: | `name` | string | required | Broadcast name/path | | `paused` | boolean | false | Pause playback | | `muted` | boolean | false | Mute audio | -| `visible` | `never` \| distance \| `always` | `0px` | When to download video (see below) | +| `visible` | never, distance, or always | `0px` | When to download video (see below) | | `volume` | number | 0.5 | Audio volume (0-1) | The `visible` attribute controls when the video track is downloaded, based on the canvas diff --git a/rs/CLAUDE.md b/rs/CLAUDE.md index b9df6160c..a1421ce75 100644 --- a/rs/CLAUDE.md +++ b/rs/CLAUDE.md @@ -9,11 +9,13 @@ Workspace members live in the root `Cargo.toml` (`[workspace]`). `rust-version = Layered roughly transport -> container/format -> media -> apps/bindings. **Transport / protocol** + - `moq-net` (lib): the core wire layer. Negotiates `moq-lite` or IETF `moq-transport`. Owns the Broadcast/Track/Group/Frame model and the Producer/Consumer split (see below). Generic over `web_transport_trait::Session` (no concrete QUIC dep). Submodules are private; the public surface is re-exported flat from the crate root. - `moq-native` (lib): native connection helpers. `ClientConfig`/`ServerConfig` wrap QUIC backends (Quinn/Quiche/Noq/Iroh), WebTransport, WebSocket, TCP (qmux), Unix sockets, TLS, cert hot-reload, logging, jemalloc. Re-exports `moq_net`. Example: `examples/clock.rs`. - `kio` (lib): "easy async". `Producer`/`Consumer` shared-state channels with `Waiter`-based notification, built on `std::task::Waker`, no runtime dependency. Underpins all the `poll_*` plumbing in moq-net and moq-mux. `src/producer.rs`, `src/consumer.rs`, `src/waiter.rs`. -**Container / catalog formats** (standalone specs, mostly no moq-* deps, reused by moq-mux) +**Container / catalog formats** (standalone specs, mostly no moq-\* deps, reused by moq-mux) + - `hang` (lib): media layer on `moq-net`. `catalog/` is the JSON manifest (`Catalog`, root.rs); `container/` is the frame format (timestamp + codec payload, `container::Frame`). - `moq-loc` (lib): LOC (Low Overhead Container) wire frame codec. Top-level `encode`/`decode` + `Frame`. QUIC varints, property KVPs. - `moq-msf` (lib): IETF MSF/CMSF catalog types (`Catalog`, `Track`, `Packaging`, `Role`). serde JSON. Alternative to hang's catalog. @@ -21,11 +23,13 @@ Layered roughly transport -> container/format -> media -> apps/bindings. - `moq-flate` (lib): group-scoped DEFLATE primitive (no moq deps). `Encoder`/`Decoder` turn a stream of payloads into self-delimited sync-flushed frames sharing one window (RFC 7692 marker trick), so similar frames compress against the earlier ones. Used by `moq-json`; reusable by any framed stream. **Media bridge / codecs** -- `moq-mux` (lib): the conversion layer. File/stream formats (`container/`: fmp4, flv, hls, mkv, ts, loc) and codec parsers (`codec/`: h264, h265, av1, vp8/9, opus, aac, ...) <-> hang broadcasts. `Container` trait + generic `Producer`/`Consumer`. Dual catalog (`catalog::hang`, `catalog::msf`). + +- `moq-mux` (lib): the conversion layer. File/stream formats (`container/`: fmp4, flv, mkv, ts, loc) and codec parsers (`codec/`: h264, h265, av1, vp8/9, opus, aac, ...) <-> hang broadcasts. `Container` trait + generic `Producer`/`Consumer`. Dual catalog (`catalog::hang`, `catalog::msf`). - `moq-audio` (lib): native PCM <-> Opus (`unsafe-libopus`). `AudioProducer`/`AudioConsumer`, `Encoder`/`Decoder`, `AudioFormat`. Optional `capture` feature (cpal microphone), `resample`. - `moq-video` (lib): native webcam capture + H.264 via `ffmpeg-next`. `capture::Config`, `encode::{Encoder, Producer, publish_capture}`. ffmpeg types kept out of the public signature (see `error/`). **Apps / binaries** + - `moq-relay` (lib+bin): clusterable, media-agnostic relay. axum HTTP API, JWT auth, WebSocket fallback, clustering. Config/TOML merge pattern lives here (see below). - `moq-cli` (lib+bin, `moq`): serve/accept/publish/subscribe; stdin/stdout media piping. - `moq-rtc` (lib+bin): WebRTC (WHIP/WHEP) gateway. Bridges browser WebRTC ingest/playback to MoQ broadcasts (str0m ICE/DTLS, A/V sync, NACK). Embeddable lib (`default-features = false`) + standalone binary (`server` feature). @@ -34,6 +38,7 @@ Layered roughly transport -> container/format -> media -> apps/bindings. - `moq-token` (lib) / `moq-token-cli` (bin): JWT auth. `Claims`, `Algorithm`, `KeyType` (EC/RSA/OCT/OKP), JWKS. CLI does generate/sign/verify. **Bindings** + - `moq-ffi` (cdylib+staticlib): UniFFI bindings (Python/Swift/Kotlin/Go). Proc-macro based (`uniffi::setup_scaffolding!("moq")`, `#[uniffi::Object]`/`#[uniffi::export]`), no `.udl`. Exposes `Moq*Producer`/`Moq*Consumer`, `MoqError` (`#[uniffi(flat_error)]`). - `libmoq` (staticlib): C bindings. `cbindgen` `build.rs` emits `moq.h` + pkg-config. `extern "C"` over opaque handles; dedicated tokio runtime thread (`LazyLock`). - `moq-gst` (cdylib): GStreamer plugin. `gst::plugin_define!`, `moqsrc`/`moqsink` elements bridging to a background tokio task. @@ -53,6 +58,7 @@ The whole stack is built on a split-handle pattern: a `Producer` writes, one or ## Async / poll plumbing Two ways to drive things, both backed by `kio`: + - `async fn` (requires an active tokio runtime; awaiting outside one may panic, see `moq-net/src/lib.rs:42`). - `poll_*` counterparts that take a `&kio::Waiter` and return `Poll<...>`, drivable from any executor or synchronously (`kio` is built on `std::task::Waker`). The `async` method usually just wraps the `poll_*` one via `kio::wait`. Example pair: `TrackConsumer::poll_recv_group` / `recv_group` (`moq-net/src/model/track.rs:502,518`). diff --git a/rs/hang/README.md b/rs/hang/README.md index dfcc39974..9106d5133 100644 --- a/rs/hang/README.md +++ b/rs/hang/README.md @@ -22,7 +22,7 @@ We most of the implement the [WebCodecs specification](https://www.w3.org/TR/web ## CMAF Import -For importing fMP4/CMAF/HLS files, see the `moq-mux` crate. +For importing fMP4/CMAF files, see the `moq-mux` crate. For HLS, see the `moq-hls` crate. ## Examples diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 2235c9972..1cbefb988 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -27,6 +27,7 @@ bytes = "1" clap = { version = "4", features = ["derive"] } hang = { workspace = true } humantime = "2.3" +moq-hls = { workspace = true, features = ["server"] } moq-mux = { workspace = true } moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"] } rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false } diff --git a/rs/moq-cli/README.md b/rs/moq-cli/README.md index 819e671b0..ad6152935 100644 --- a/rs/moq-cli/README.md +++ b/rs/moq-cli/README.md @@ -56,4 +56,3 @@ moq-cli accept --broadcast my-stream --format fmp4 | ffplay - - `avc3` raw H.264 Annex-B from stdin - `fmp4` fragmented MP4 from stdin -- `hls --playlist ` ingest from an HLS playlist diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index 3c29343a5..152330d65 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -12,7 +12,11 @@ use subscribe::*; use web::*; use clap::{Parser, Subcommand}; +use std::net::SocketAddr; use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tower_http::cors::{Any, CorsLayer}; use url::Url; #[derive(Parser, Clone)] @@ -109,6 +113,51 @@ pub enum Command { #[command(flatten)] args: SubscribeArgs, }, + /// Import or export HLS / LL-HLS via a MoQ relay. + Hls { + /// The MoQ client configuration. + #[command(flatten)] + config: moq_native::ClientConfig, + + /// The URL of the MoQ server. + #[arg(long, alias = "relay", env = "MOQ_HLS_RELAY")] + url: Url, + + #[command(subcommand)] + command: HlsCommand, + }, +} + +#[derive(Subcommand, Clone)] +pub enum HlsCommand { + /// Serve HLS / LL-HLS over HTTP from MoQ broadcasts. + Export { + /// HTTP listener for the HLS endpoints. + #[arg(long, env = "MOQ_HLS_LISTEN", default_value = "[::]:8089")] + listen: SocketAddr, + + /// TLS certificates, keys, self-signed generation, and optional mTLS roots. + #[command(flatten)] + tls: moq_native::tls::Server, + + /// LL-HLS part target duration. + #[arg(long, env = "MOQ_HLS_PART_TARGET", default_value = "500ms", value_parser = humantime::parse_duration)] + part_target: Duration, + + /// Minimum duration of media kept in each rendition's sliding window. + #[arg(long, env = "MOQ_HLS_WINDOW", default_value = "16s", value_parser = humantime::parse_duration)] + window: Duration, + }, + /// Pull a remote HLS master/media playlist and publish it into MoQ. + Import { + /// Broadcast name to publish on the relay. + #[arg(long, alias = "name", env = "MOQ_HLS_BROADCAST")] + broadcast: String, + + /// Remote HLS playlist URL (http/https) or local file path. + #[arg(long, env = "MOQ_HLS_PLAYLIST")] + playlist: String, + }, } #[tokio::main] @@ -200,6 +249,22 @@ async fn main() -> anyhow::Result<()> { run_subscribe(client, url, broadcast, args).await } + Command::Hls { config, url, command } => { + let client = config.init()?; + + #[cfg(feature = "iroh")] + let client = client.with_iroh(iroh); + + match command { + HlsCommand::Export { + listen, + tls, + part_target, + window, + } => run_hls_export(client, url, listen, tls, part_target, window).await, + HlsCommand::Import { broadcast, playlist } => run_hls_import(client, url, broadcast, playlist).await, + } + } } } @@ -249,3 +314,125 @@ async fn run_announced_subscribe( Subscribe::new(consumer, catalog, args).run().await } + +async fn run_hls_export( + client: moq_native::Client, + url: Url, + listen: SocketAddr, + tls: moq_native::tls::Server, + part_target: Duration, + window: Duration, +) -> anyhow::Result<()> { + let subscriber = moq_net::Origin::random().produce(); + let consumer = subscriber.consume(); + let reconnect = client.with_consume(subscriber).reconnect(url.clone()); + + let config = moq_hls::export::Config { + part_target, + window, + ..Default::default() + }; + let server = moq_hls::Server::new(consumer, config); + let app = server + .router() + .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any)); + + let tls = if tls.cert.is_empty() && tls.generate.is_empty() { + None + } else { + let alpn = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Some(tls.server_config(alpn)?) + }; + + let listener = moq_native::bind::tcp(listen)?; + + tracing::info!(%url, %listen, "serving HLS"); + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + tokio::select! { + res = serve_hls(listener, app, tls) => res, + res = reconnect.closed() => res.map_err(Into::into), + _ = shutdown_signal() => Ok(()), + } +} + +async fn run_hls_import( + client: moq_native::Client, + url: Url, + broadcast: String, + playlist: String, +) -> anyhow::Result<()> { + warn_if_missing_format(&broadcast); + + let publisher = moq_net::Origin::random().produce(); + let reconnect = client.with_publish(publisher.consume()).reconnect(url.clone()); + + let mut producer = moq_net::Broadcast::new().produce(); + let consumer = producer.consume(); + anyhow::ensure!( + publisher.publish_broadcast(&broadcast, consumer), + "failed to publish broadcast" + ); + + let catalog = moq_mux::catalog::Producer::new(&mut producer)?; + let mut importer = moq_hls::import::Import::new(producer, catalog, moq_hls::import::Config::new(playlist))?; + + tracing::info!(%url, %broadcast, "importing HLS"); + + tokio::select! { + res = async { + importer.init().await?; + + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); + + importer.run().await + } => res.map_err(Into::into), + res = reconnect.closed() => res.map_err(Into::into), + _ = shutdown_signal() => Ok(()), + } +} + +async fn serve_hls( + listener: std::net::TcpListener, + app: axum::Router, + tls: Option>, +) -> anyhow::Result<()> { + let service = app.into_make_service(); + match tls { + Some(config) => { + let config = axum_server::tls_rustls::RustlsConfig::from_config(config); + axum_server::from_tcp_rustls(listener, config)?.serve(service).await?; + } + None => { + axum_server::from_tcp(listener)?.serve(service).await?; + } + } + Ok(()) +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + + let mut term = match signal(SignalKind::terminate()) { + Ok(term) => term, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + } + + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index cc2c0ba9e..853774309 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -99,6 +99,7 @@ impl Publish { pub async fn run(self) -> anyhow::Result<()> { let mut decoder = self.source; + let mut stdin = tokio::io::stdin(); let mut buffer = bytes::BytesMut::new(); diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index ceb464835..bf41ffd3c 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -15,50 +15,27 @@ categories = ["multimedia", "network-programming", "web-programming"] [lib] doctest = false -[[bin]] -name = "moq-hls" -path = "bin/moq-hls.rs" -doc = false -# The binary (and the HTTP export server) need the `server` feature; the library -# can be depended on with `default-features = false` for import only (e.g. moq-cli). -required-features = ["server"] - [features] -default = ["server", "iroh", "noq", "websocket"] -# HTTP export server + the moq-hls binary. Pulls in axum / moq-native / clap. -server = ["dep:axum", "dep:axum-server", "dep:clap", "dep:humantime", "dep:moq-native", "dep:rustls", "dep:sd-notify", "dep:tower-http"] -iroh = ["server", "moq-native/iroh"] -noq = ["server", "moq-native/noq"] -quinn = ["server", "moq-native/quinn"] -quiche = ["server", "moq-native/quiche"] -websocket = ["server", "moq-native/websocket"] +default = ["server"] +server = ["dep:axum"] [dependencies] # Always required (import + export library). anyhow = { version = "1", features = ["backtrace"] } -# Only needed by the HTTP export server / binary (gated by `server`). +# Only needed by the HTTP export server (gated by `server`). axum = { version = "0.8", features = ["tokio"], optional = true } -axum-server = { version = "0.8", features = ["tls-rustls"], optional = true } bytes = "1" -clap = { version = "4", features = ["derive"], optional = true } hang = { workspace = true } -humantime = { version = "2.3", optional = true } kio = { workspace = true } m3u8-rs = "6" moq-mux = { workspace = true } -moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"], optional = true } moq-net = { workspace = true } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] } -rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false, optional = true } thiserror = "2" tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.6", features = ["cors"], optional = true } tracing = "0.1" url = "2" -[target.'cfg(unix)'.dependencies] -sd-notify = { version = "0.5", optional = true } - [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/rs/moq-hls/bin/moq-hls.rs b/rs/moq-hls/bin/moq-hls.rs deleted file mode 100644 index 068a37d14..000000000 --- a/rs/moq-hls/bin/moq-hls.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! `moq-hls` binary. -//! -//! Two subcommands under shared relay/client globals: -//! -//! - `export` -- subscribe to MoQ broadcasts and serve HLS + LL-HLS over HTTP -//! (an HTTP *server* that *subscribes*; the WHEP-server analogue in `moq-rtc`). -//! - `import` -- pull a remote HLS playlist and publish it into MoQ (an HTTP -//! *client* that *publishes*; the WHEP-client analogue in `moq-rtc`). -//! -//! HLS isn't a symmetric push/pull protocol like WHIP/WHEP, so these are -//! explicit subcommands rather than a `server`/`client` x `publish`/`subscribe` -//! matrix. - -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Context; -use axum::Router; -use clap::{Parser, Subcommand}; -use moq_hls::Server; -use tower_http::cors::{Any, CorsLayer}; -use url::Url; - -#[derive(Parser, Clone)] -#[command(version)] -struct Cli { - #[command(flatten)] - log: moq_native::Log, - - /// MoQ client configuration for dialing the upstream relay. - #[command(flatten)] - moq_client: moq_native::ClientConfig, - - /// URL of the upstream MoQ relay to publish into (import) or read from (export). - #[arg(long, env = "MOQ_HLS_RELAY")] - relay: Url, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Clone)] -enum Command { - /// Serve HLS / LL-HLS over HTTP from MoQ broadcasts (path-based, multi-broadcast). - Export { - /// HTTP listener for the HLS endpoints. - #[arg(long, env = "MOQ_HLS_LISTEN", default_value = "[::]:8089")] - listen: SocketAddr, - - /// TLS certificates, keys, self-signed generation, and optional mTLS roots. - /// Serve HTTPS by setting `--tls-cert`/`--tls-key` or `--tls-generate`. - /// Most players require HTTPS. - #[command(flatten)] - tls: moq_native::tls::Server, - - /// LL-HLS part target duration (also caps the exporter's fragment duration). - #[arg(long, env = "MOQ_HLS_PART_TARGET", default_value = "500ms", value_parser = humantime::parse_duration)] - part_target: Duration, - - /// Minimum duration of media kept in each rendition's sliding window. - #[arg(long, env = "MOQ_HLS_WINDOW", default_value = "16s", value_parser = humantime::parse_duration)] - window: Duration, - }, - /// Pull a remote HLS master/media playlist and publish it into MoQ. - Import { - /// Broadcast name to publish on the relay. - #[arg(long, alias = "name", env = "MOQ_HLS_BROADCAST")] - broadcast: String, - - /// Remote HLS playlist URL (http/https) or local file path. - #[arg(long, env = "MOQ_HLS_PLAYLIST")] - playlist: String, - }, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .expect("failed to install default crypto provider"); - - let Cli { - log, - moq_client, - relay, - command, - } = Cli::parse(); - log.init()?; - - let client = moq_client.init().context("failed to init moq client")?; - - match command { - Command::Export { - listen, - tls, - part_target, - window, - } => { - let subscriber = moq_net::Origin::random().produce(); - let subscriber_consumer = subscriber.consume(); - let reconnect = client.with_consume(subscriber).reconnect(relay.clone()); - - let config = moq_hls::export::Config { - part_target, - window, - ..Default::default() - }; - let server = Server::new(subscriber_consumer, config); - let app = server - .router() - .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any)); - - // Serve HTTPS only when a cert/key pair or self-signed generation is configured. - let tls = if tls.cert.is_empty() && tls.generate.is_empty() { - None - } else { - let alpn = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - Some(tls.server_config(alpn).context("failed to build TLS config")?) - }; - - // Bind before signaling readiness so a port conflict surfaces as a startup - // failure instead of systemd briefly seeing a dead instance as healthy. - let listener = std::net::TcpListener::bind(listen).context("failed to bind HLS listener")?; - - tracing::info!(%relay, %listen, "moq-hls serving HLS"); - - #[cfg(unix)] - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); - - tokio::select! { - res = serve(listener, app, tls) => res, - res = reconnect.closed() => res.map_err(Into::into), - _ = shutdown_signal() => Ok(()), - } - } - Command::Import { broadcast, playlist } => { - let publisher = moq_net::Origin::random().produce(); - let reconnect = client.with_publish(publisher.consume()).reconnect(relay.clone()); - - let mut producer = moq_net::Broadcast::new().produce(); - let consumer = producer.consume(); - anyhow::ensure!( - publisher.publish_broadcast(&broadcast, consumer), - "failed to publish broadcast" - ); - - let catalog = moq_mux::catalog::Producer::new(&mut producer).context("failed to create catalog")?; - let mut importer = moq_hls::import::Import::new(producer, catalog, moq_hls::import::Config::new(playlist))?; - - tracing::info!(%relay, %broadcast, "moq-hls importing HLS"); - - tokio::select! { - res = async { - // Signal readiness only once the source is validated and primed, not - // before, so a bad playlist URL fails startup instead of reporting healthy. - importer.init().await?; - - #[cfg(unix)] - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); - - importer.run().await - } => res.map_err(Into::into), - res = reconnect.closed() => res.map_err(Into::into), - _ = shutdown_signal() => Ok(()), - } - } - } -} - -/// Resolve when the process is asked to shut down: Ctrl-C, or SIGTERM on Unix -/// (which is how systemd and most supervisors stop a service). -async fn shutdown_signal() { - #[cfg(unix)] - { - use tokio::signal::unix::{SignalKind, signal}; - - let mut term = match signal(SignalKind::terminate()) { - Ok(term) => term, - Err(_) => { - let _ = tokio::signal::ctrl_c().await; - return; - } - }; - tokio::select! { - _ = tokio::signal::ctrl_c() => {} - _ = term.recv() => {} - } - } - - #[cfg(not(unix))] - { - let _ = tokio::signal::ctrl_c().await; - } -} - -async fn serve( - listener: std::net::TcpListener, - app: Router, - tls: Option>, -) -> anyhow::Result<()> { - let service = app.into_make_service(); - match tls { - Some(config) => { - let config = axum_server::tls_rustls::RustlsConfig::from_config(config); - axum_server::from_tcp_rustls(listener, config)?.serve(service).await?; - } - None => { - axum_server::from_tcp(listener)?.serve(service).await?; - } - } - Ok(()) -} diff --git a/rs/moq-mux/README.md b/rs/moq-mux/README.md index 9c02d550e..87732191e 100644 --- a/rs/moq-mux/README.md +++ b/rs/moq-mux/README.md @@ -11,7 +11,7 @@ Media muxers and demuxers for [Media over QUIC](https://moq.dev). Takes containerized or raw-codec media in, produces a [hang](https://github.com/moq-dev/moq/tree/main/rs/hang) broadcast — or the other way around. -**Containers:** fMP4 / CMAF, MKV / WebM, HLS, LOC, hang Legacy. +**Containers:** fMP4 / CMAF, MKV / WebM, MPEG-TS, FLV, LOC, hang Legacy. **Codecs:** H.264, H.265, AV1, AAC, Opus. The crate splits along two axes: diff --git a/rs/moq-mux/src/container/hls/import.rs b/rs/moq-mux/src/container/hls/import.rs deleted file mode 100644 index 6c1759e24..000000000 --- a/rs/moq-mux/src/container/hls/import.rs +++ /dev/null @@ -1,616 +0,0 @@ -//! HLS import: pull an HLS master/media playlist and publish it into MoQ. -//! -//! Watches an HLS master or media playlist, downloads each fMP4 segment as it -//! appears, and feeds it through moq-mux's fMP4 importer (which publishes a -//! `hang` broadcast + catalog). Classic HLS only for now (no LL-HLS partial -//! segments on the import side). - -use std::collections::HashMap; -use std::collections::hash_map::Entry; -use std::path::PathBuf; -use std::time::Duration; - -use bytes::Bytes; -use m3u8_rs::{ - AlternativeMedia, AlternativeMediaType, Map, MasterPlaylist, MediaPlaylist, MediaSegment, Resolution, VariantStream, -}; -use moq_mux::catalog::Producer as CatalogProducer; -use moq_mux::container::fmp4::Import as Fmp4; -use reqwest::Client; -use tracing::{debug, info, warn}; -use url::Url; - -use crate::{Error, Result}; - -/// Configuration for the single-rendition HLS import loop. -#[derive(Clone)] -pub struct Config { - /// The master or media playlist URL or file path to import. - pub playlist: String, - - /// An optional HTTP client to use for fetching the playlist and segments. - /// If not provided, a default client will be created. - pub client: Option, -} - -impl Config { - pub fn new(playlist: String) -> Self { - Self { playlist, client: None } - } - - /// Parse the playlist string into a URL. - /// If it starts with http:// or https://, parse as URL. - /// Otherwise, treat as a file path and convert to file:// URL. - fn parse_playlist(&self) -> Result { - if self.playlist.starts_with("http://") || self.playlist.starts_with("https://") { - Url::parse(&self.playlist).map_err(|_| Error::InvalidPlaylistUrl) - } else { - let path = PathBuf::from(&self.playlist); - let absolute = if path.is_absolute() { - path - } else { - std::env::current_dir()?.join(path) - }; - Url::from_file_path(&absolute).map_err(|_| Error::InvalidFilePath) - } - } -} - -/// Result of a single import step. -struct StepOutcome { - /// Number of media segments written during this step. - pub wrote_segments: usize, - /// Target segment duration (in seconds) from the playlist, if known. - pub target_duration: Option, -} - -/// HLS import that pulls an HLS media playlist and feeds the bytes into the fMP4 importer. -/// -/// Provides `init()` to prime the importer with initial segments, and `run()` -/// to run the continuous import loop. -pub struct Import { - /// Broadcast that all CMAF importers write into. - broadcast: moq_net::BroadcastProducer, - - /// The catalog being produced. - catalog: CatalogProducer, - - /// fMP4 importers for each discovered video rendition. - /// Each importer feeds a separate MoQ track but shares the same catalog. - video_importers: Vec, - - /// fMP4 importer for the selected audio rendition, if any. - audio_importer: Option, - - client: Client, - /// Parsed base URL for the playlist (file:// or http(s)://). - base_url: Url, - /// All discovered video variants (one per HLS rendition). - video: Vec, - /// Optional audio track shared across variants. - audio: Option, -} - -#[derive(Debug, Clone, Copy)] -enum TrackKind { - Video(usize), - Audio, -} - -struct TrackState { - playlist: Url, - next_sequence: Option, - init_ready: bool, -} - -impl TrackState { - fn new(playlist: Url) -> Self { - Self { - playlist, - next_sequence: None, - init_ready: false, - } - } -} - -impl Import { - /// Create a new HLS import that will write into the given broadcast. - pub fn new(broadcast: moq_net::BroadcastProducer, catalog: CatalogProducer, cfg: Config) -> Result { - let base_url = cfg.parse_playlist()?; - let client = cfg.client.unwrap_or_else(|| { - Client::builder() - .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) - .build() - .unwrap() - }); - Ok(Self { - broadcast, - catalog, - video_importers: Vec::new(), - audio_importer: None, - client, - base_url, - video: Vec::new(), - audio: None, - }) - } - - /// Fetch the latest playlist, download the init segment, and prime the importer with a buffer of segments. - /// - /// Returns the number of segments buffered during initialization. - pub async fn init(&mut self) -> Result<()> { - let buffered = self.prime().await?; - if buffered == 0 { - warn!("HLS playlist had no new segments during init step"); - } else { - info!(count = buffered, "buffered initial HLS segments"); - } - Ok(()) - } - - /// Run the import loop until cancelled. - pub async fn run(&mut self) -> Result<()> { - loop { - let outcome = self.step().await?; - let delay = self.refresh_delay(outcome.target_duration, outcome.wrote_segments); - - info!( - wrote_segments = outcome.wrote_segments, - target_duration = ?outcome.target_duration, - delay_secs = delay.as_secs_f32(), - "HLS import step complete" - ); - - tokio::time::sleep(delay).await; - } - } - - /// Internal: fetch the latest playlist, download the init segment, and buffer segments. - async fn prime(&mut self) -> Result { - self.ensure_tracks().await?; - - let mut buffered = 0usize; - const MAX_INIT_SEGMENTS: usize = 3; // Only process a few segments during init to avoid getting ahead of live stream - - // Prime all discovered video variants. - // - // Move the video track states out of `self` so we can safely mutate both - // the importer and the tracks without running into borrow checker issues. - let video_tracks = std::mem::take(&mut self.video); - for (index, mut track) in video_tracks.into_iter().enumerate() { - let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; - let count = self - .consume_segments(TrackKind::Video(index), &mut track, &playlist, Some(MAX_INIT_SEGMENTS)) - .await?; - buffered += count; - self.video.push(track); - } - - // Prime the shared audio track, if any. - if let Some(mut track) = self.audio.take() { - let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; - let count = self - .consume_segments(TrackKind::Audio, &mut track, &playlist, Some(MAX_INIT_SEGMENTS)) - .await?; - buffered += count; - self.audio = Some(track); - } - - Ok(buffered) - } - - /// Perform a single import step for all active tracks. - /// - /// This fetches the current media playlists, consumes any fresh segments, - /// and returns how many segments were written along with the target - /// duration to guide scheduling of the next step. - async fn step(&mut self) -> Result { - self.ensure_tracks().await?; - - let mut wrote = 0usize; - let mut target_duration = None; - - // Ingest a step from all active video variants. - let video_tracks = std::mem::take(&mut self.video); - for (index, mut track) in video_tracks.into_iter().enumerate() { - let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; - // Use the first video's target duration as the base. - if target_duration.is_none() { - target_duration = Some(playlist.target_duration); - } - let count = self - .consume_segments(TrackKind::Video(index), &mut track, &playlist, None) - .await?; - wrote += count; - self.video.push(track); - } - - // Ingest from the shared audio track, if present. - if let Some(mut track) = self.audio.take() { - let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; - if target_duration.is_none() { - target_duration = Some(playlist.target_duration); - } - let count = self - .consume_segments(TrackKind::Audio, &mut track, &playlist, None) - .await?; - wrote += count; - self.audio = Some(track); - } - - Ok(StepOutcome { - wrote_segments: wrote, - target_duration, - }) - } - - /// Compute the delay before the next import step should run. - fn refresh_delay(&self, target_duration: Option, wrote_segments: usize) -> Duration { - let base = target_duration - .map(|dur| Duration::from_secs(dur.max(1))) - .unwrap_or_else(|| Duration::from_millis(500)); - if wrote_segments == 0 { - return base / 2; - } - - base - } - - async fn fetch_media_playlist(&self, url: Url) -> Result { - let body = self.fetch_bytes(url).await?; - - // Nom errors take ownership of the input, so we need to stringify any error messages. - let playlist = m3u8_rs::parse_media_playlist_res(&body).map_err(|e| Error::ParsePlaylist(e.to_string()))?; - - Ok(playlist) - } - - async fn ensure_tracks(&mut self) -> Result<()> { - // Tracks already discovered. - if !self.video.is_empty() { - return Ok(()); - } - - let body = self.fetch_bytes(self.base_url.clone()).await?; - if let Ok((_, master)) = m3u8_rs::parse_master_playlist(&body) { - let variants = select_variants(&master); - if variants.is_empty() { - return Err(Error::NoVariants); - } - - // Create a video track state for every usable variant. - for variant in &variants { - let video_url = resolve_uri(&self.base_url, &variant.uri)?; - self.video.push(TrackState::new(video_url)); - } - - // Choose an audio rendition based on the first variant with an audio group. - if let Some(group_id) = variants.iter().find_map(|v| v.audio.as_deref()) { - if let Some(audio_tag) = select_audio(&master, group_id) { - if let Some(uri) = &audio_tag.uri { - let audio_url = resolve_uri(&self.base_url, uri)?; - self.audio = Some(TrackState::new(audio_url)); - } else { - warn!(%group_id, "audio rendition missing URI"); - } - } else { - warn!(%group_id, "audio group not found in master playlist"); - } - } - - let audio_url = self.audio.as_ref().map(|a| a.playlist.to_string()); - info!( - video_variants = variants.len(), - audio = audio_url.as_deref().unwrap_or("none"), - "selected master playlist renditions" - ); - - return Ok(()); - } - - // Fallback: treat the provided URL as a single media playlist. - self.video.push(TrackState::new(self.base_url.clone())); - Ok(()) - } - - async fn consume_segments( - &mut self, - kind: TrackKind, - track: &mut TrackState, - playlist: &MediaPlaylist, - limit: Option, - ) -> Result { - self.ensure_init_segment(kind, track, playlist).await?; - - let next_seq = track.next_sequence.unwrap_or(0); - let playlist_seq = playlist.media_sequence; - let total_segments = playlist.segments.len(); - let last_playlist_seq = playlist_seq + total_segments as u64; - - let skip = if next_seq > last_playlist_seq { - warn!( - ?kind, - next_sequence = next_seq, - playlist_sequence = playlist_seq, - last_playlist_sequence = last_playlist_seq, - "imported ahead of playlist, waiting for new segments" - ); - total_segments - } else if next_seq < playlist_seq { - warn!( - ?kind, - next_sequence = next_seq, - playlist_sequence = playlist_seq, - "next_sequence behind playlist, resetting to start of playlist" - ); - track.next_sequence = None; - 0 - } else { - (next_seq - playlist_seq) as usize - }; - - let available = total_segments.saturating_sub(skip); - let to_process = match limit { - Some(max) => available.min(max), - None => available, - }; - - info!( - ?kind, - playlist_sequence = playlist_seq, - next_sequence = next_seq, - skip = skip, - total_segments = total_segments, - to_process = to_process, - "consuming HLS segments" - ); - - if to_process > 0 { - let base_seq = playlist_seq + skip as u64; - for (i, segment) in playlist.segments[skip..skip + to_process].iter().enumerate() { - self.push_segment(kind, track, segment, base_seq + i as u64).await?; - } - info!(?kind, consumed = to_process, "consumed HLS segments"); - } else { - debug!(?kind, "no fresh HLS segments available"); - } - - Ok(to_process) - } - - async fn ensure_init_segment( - &mut self, - kind: TrackKind, - track: &mut TrackState, - playlist: &MediaPlaylist, - ) -> Result<()> { - if track.init_ready { - return Ok(()); - } - - let map = self.find_map(playlist).ok_or(Error::MissingMap)?; - - let url = resolve_uri(&track.playlist, &map.uri)?; - let bytes = self.fetch_bytes(url).await?; - let importer = match kind { - TrackKind::Video(index) => self.ensure_video_importer_for(index), - TrackKind::Audio => self.ensure_audio_importer(), - }; - - // The importer buffers internally, so a fully-parsed init segment leaves it - // initialized; any trailing partial atom just waits for the next segment. A - // segment that never yields a moov surfaces later as a decode error. - importer.decode(&bytes)?; - - track.init_ready = true; - info!(?kind, "loaded HLS init segment"); - Ok(()) - } - - async fn push_segment( - &mut self, - kind: TrackKind, - track: &mut TrackState, - segment: &MediaSegment, - sequence: u64, - ) -> Result<()> { - if segment.uri.is_empty() { - return Err(Error::EmptySegmentUri); - } - - let url = resolve_uri(&track.playlist, &segment.uri)?; - let bytes = self.fetch_bytes(url).await?; - - // Ensure the importer is initialized before processing fragments - // Use track.init_ready to avoid borrowing issues - if !track.init_ready { - // Try to ensure init segment is processed - let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; - self.ensure_init_segment(kind, track, &playlist).await?; - } - - // Get importer after ensuring init segment - let importer = match kind { - TrackKind::Video(index) => self.ensure_video_importer_for(index), - TrackKind::Audio => self.ensure_audio_importer(), - }; - - importer.decode(&bytes)?; - track.next_sequence = Some(sequence + 1); - - Ok(()) - } - - fn find_map<'a>(&self, playlist: &'a MediaPlaylist) -> Option<&'a Map> { - playlist.segments.iter().find_map(|segment| segment.map.as_ref()) - } - - async fn fetch_bytes(&self, url: Url) -> Result { - if url.scheme() == "file" { - let path = url.to_file_path().map_err(|_| Error::InvalidFileUrl)?; - let bytes = tokio::fs::read(&path).await.map_err(Error::from)?; - Ok(Bytes::from(bytes)) - } else { - let response = self.client.get(url).send().await.map_err(Error::from)?; - let response = response.error_for_status().map_err(Error::from)?; - let bytes = response.bytes().await.map_err(Error::from)?; - Ok(bytes) - } - } - - /// Create or retrieve the fMP4 importer for a specific video rendition. - /// - /// Each video variant gets its own importer so that their tracks remain - /// independent while still contributing to the same shared catalog. - fn ensure_video_importer_for(&mut self, index: usize) -> &mut Fmp4 { - while self.video_importers.len() <= index { - let importer = Fmp4::new(self.broadcast.clone(), self.catalog.clone()); - self.video_importers.push(importer); - } - - self.video_importers.get_mut(index).unwrap() - } - - /// Create or retrieve the fMP4 importer for the audio rendition. - fn ensure_audio_importer(&mut self) -> &mut Fmp4 { - self.audio_importer - .get_or_insert_with(|| Fmp4::new(self.broadcast.clone(), self.catalog.clone())) - } - - #[cfg(test)] - fn has_video_importer(&self) -> bool { - !self.video_importers.is_empty() - } - - #[cfg(test)] - fn has_audio_importer(&self) -> bool { - self.audio_importer.is_some() - } -} - -fn select_audio<'a>(master: &'a MasterPlaylist, group_id: &str) -> Option<&'a AlternativeMedia> { - let mut first = None; - let mut default = None; - - for alternative in master - .alternatives - .iter() - .filter(|alt| alt.media_type == AlternativeMediaType::Audio && alt.group_id == group_id) - { - if first.is_none() { - first = Some(alternative); - } - if alternative.default { - default = Some(alternative); - break; - } - } - - default.or(first) -} - -fn select_variants(master: &MasterPlaylist) -> Vec<&VariantStream> { - // Helper to extract the first video codec token from the CODECS attribute. - fn first_video_codec(variant: &VariantStream) -> Option<&str> { - let codecs = variant.codecs.as_deref()?; - codecs.split(',').map(|s| s.trim()).find(|s| !s.is_empty()) - } - - // Map codec strings into a coarse "family" so we can prefer H.264 over others. - fn codec_family(codec: &str) -> Option<&'static str> { - if codec.starts_with("avc1.") || codec.starts_with("avc3.") { - Some("h264") - } else { - None - } - } - - // Consider only non-i-frame variants with a URI and a known codec family. - let candidates: Vec<(&VariantStream, &str, &str)> = master - .variants - .iter() - .filter(|variant| !variant.is_i_frame && !variant.uri.is_empty()) - .filter_map(|variant| { - let codec = first_video_codec(variant)?; - let family = codec_family(codec)?; - Some((variant, codec, family)) - }) - .collect(); - - if candidates.is_empty() { - return Vec::new(); - } - - // Prefer families in this order, falling back to the first available. - const FAMILY_PREFERENCE: &[&str] = &["h264"]; - - let families_present: Vec<&str> = candidates.iter().map(|(_, _, fam)| *fam).collect(); - - let target_family = FAMILY_PREFERENCE - .iter() - .find(|fav| families_present.iter().any(|fam| fam == *fav)) - .copied() - .unwrap_or(families_present[0]); - - // Keep only variants in the chosen family. - let family_variants: Vec<&VariantStream> = candidates - .into_iter() - .filter(|(_, _, fam)| *fam == target_family) - .map(|(variant, _, _)| variant) - .collect(); - - // Deduplicate by resolution, keeping the lowest-bandwidth variant for each size. - let mut by_resolution: HashMap, &VariantStream> = HashMap::new(); - - for variant in family_variants { - let key = variant.resolution; - let bandwidth = variant.average_bandwidth.unwrap_or(variant.bandwidth); - - match by_resolution.entry(key) { - Entry::Vacant(entry) => { - entry.insert(variant); - } - Entry::Occupied(mut entry) => { - let existing = entry.get(); - let existing_bw = existing.average_bandwidth.unwrap_or(existing.bandwidth); - if bandwidth < existing_bw { - entry.insert(variant); - } - } - } - } - - by_resolution.values().cloned().collect() -} - -fn resolve_uri(base: &Url, value: &str) -> std::result::Result { - if let Ok(url) = Url::parse(value) { - return Ok(url); - } - - base.join(value) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hls_config_new_sets_fields() { - let url = "https://example.com/stream.m3u8".to_string(); - let cfg = Config::new(url.clone()); - assert_eq!(cfg.playlist, url); - } - - #[test] - fn hls_import_starts_without_importers() { - let mut broadcast = moq_net::Broadcast::new().produce(); - let catalog = CatalogProducer::new(&mut broadcast).unwrap(); - let url = "https://example.com/master.m3u8".to_string(); - let cfg = Config::new(url); - let hls = Import::new(broadcast, catalog, cfg).unwrap(); - - assert!(!hls.has_video_importer()); - assert!(!hls.has_audio_importer()); - } -} diff --git a/rs/moq-rtmp/README.md b/rs/moq-rtmp/README.md index 2d6467f2e..aafb0fee7 100644 --- a/rs/moq-rtmp/README.md +++ b/rs/moq-rtmp/README.md @@ -7,7 +7,7 @@ both directions with [`moq-mux`](../moq-mux): on **publish** it re-wraps a client's messages as FLV tags, demuxes them, and publishes the result into a MoQ origin as ordinary broadcasts; on **play** it subscribes to a broadcast from the origin, muxes it back to FLV, and streams the tags down to the player. It's the -sibling of `moq-srt`, `moq-hls`'s import/export, and `moq-rtc`'s WHIP/WHEP. Both +sibling of `moq-srt`, `moq-cli hls` import/export, and `moq-rtc`'s WHIP/WHEP. Both legacy RTMP (H.264 + AAC) and enhanced RTMP (E-RTMP: HEVC, AV1, VP9, Opus, AC-3) work in each direction, since the codec handling lives in the `moq-mux` FLV demuxer/muxer. Pure Rust: the protocol is provided by `rml_rtmp`, with no librtmp @@ -127,7 +127,7 @@ moq-rtmp serve --server-bind [::]:443 --tls-generate localhost \ ``` `publish` instead forwards every ingested broadcast out to a remote relay over -WebTransport (like `moq-srt publish` / `moq-hls import`): +WebTransport (like `moq-srt publish` / `moq-cli hls import`): ```bash moq-rtmp publish --relay https://relay.example.com \ diff --git a/rs/moq-rtmp/bin/moq-rtmp.rs b/rs/moq-rtmp/bin/moq-rtmp.rs index 1c49be356..a5838ca63 100644 --- a/rs/moq-rtmp/bin/moq-rtmp.rs +++ b/rs/moq-rtmp/bin/moq-rtmp.rs @@ -9,7 +9,7 @@ //! straight to this binary (no separate relay needed). RTMP players can also //! pull the same broadcasts back out. //! - `publish` forwards every ingested broadcast out to a remote relay over -//! WebTransport, like `moq-srt publish` / `moq-hls import` / `moq-rtc` WHIP. +//! WebTransport, like `moq-srt publish` / `moq-cli hls import` / `moq-rtc` WHIP. //! //! A relay that wants in-process ingest/egress should instead depend on the //! `moq-rtmp` library and call `moq_rtmp::run` against its own origin. diff --git a/rs/moq-rtmp/src/lib.rs b/rs/moq-rtmp/src/lib.rs index b5b5b7699..31cb1dfda 100644 --- a/rs/moq-rtmp/src/lib.rs +++ b/rs/moq-rtmp/src/lib.rs @@ -8,11 +8,11 @@ //! its audio/video messages as FLV tags, demux them with [`moq_mux`], and //! publish the result into a [`moq_net::OriginProducer`] as ordinary MoQ //! broadcasts. This is the contribution-ingest analogue of `moq-srt`, -//! `moq-hls`'s import, and `moq-rtc`'s WHIP. +//! `moq-cli hls import`, and `moq-rtc`'s WHIP. //! - **Play (egress)**: a client (VLC, ffplay, mpv) pulls //! `rtmp://host//`; we subscribe to that broadcast from a //! [`moq_net::OriginConsumer`], mux it back to FLV with [`moq_mux`], and stream -//! the tags down as RTMP. The counterpart to `moq-hls`'s export. +//! the tags down as RTMP. The counterpart to `moq-cli hls export`. //! //! Both legacy RTMP (H.264 + AAC) and enhanced RTMP (E-RTMP: the HEVC, AV1, VP9, //! Opus, and AC-3 FourCC payloads) are supported in each direction, because the diff --git a/rs/moq-srt/README.md b/rs/moq-srt/README.md index a4c737f87..5bd12c3e1 100644 --- a/rs/moq-srt/README.md +++ b/rs/moq-srt/README.md @@ -7,7 +7,7 @@ by its stream id `m=` mode: - `m=publish` (the default): ingest. Demux the connection's transport stream with [`moq-mux`](../moq-mux) and publish it into a MoQ origin as an ordinary - broadcast. The contribution-ingest analogue of `moq-hls`'s import and + broadcast. The contribution-ingest analogue of `moq-cli hls import` and `moq-rtc`'s WHIP. - `m=request`: egress. Re-mux a broadcast from the origin back to MPEG-TS and stream it to the caller, so `vlc srt://...` and `ffmpeg -i srt://...` can play @@ -54,7 +54,7 @@ moq-srt serve --server-bind [::]:443 --tls-generate localhost \ ``` `publish` instead forwards every ingested broadcast out to a remote relay over -WebTransport (like `moq-hls import`): +WebTransport (like `moq-cli hls import`): ```bash moq-srt publish --relay https://relay.example.com \ diff --git a/rs/moq-srt/bin/moq-srt.rs b/rs/moq-srt/bin/moq-srt.rs index d638ce399..82c525042 100644 --- a/rs/moq-srt/bin/moq-srt.rs +++ b/rs/moq-srt/bin/moq-srt.rs @@ -7,7 +7,7 @@ //! straight to this binary (no separate relay needed). Ingested broadcasts are //! also requestable back out over SRT. //! - `publish` forwards every ingested broadcast out to a remote relay over -//! WebTransport, like `moq-hls import` / `moq-rtc` WHIP. SRT requests are +//! WebTransport, like `moq-cli hls import` / `moq-rtc` WHIP. SRT requests are //! served from the local origin (broadcasts ingested by this same process). //! //! A relay that wants an in-process gateway should instead depend on the diff --git a/rs/moq-srt/src/lib.rs b/rs/moq-srt/src/lib.rs index cc080f490..e70fc7d1b 100644 --- a/rs/moq-srt/src/lib.rs +++ b/rs/moq-srt/src/lib.rs @@ -6,7 +6,7 @@ //! //! - `m=publish` (the default): demux the MPEG-TS the connection carries with //! [`moq_mux`] and publish it into the origin as an ordinary broadcast. The -//! contribution-ingest analogue of `moq-hls`'s import and `moq-rtc`'s WHIP. +//! contribution-ingest analogue of `moq-cli hls import` and `moq-rtc`'s WHIP. //! - `m=request`: re-mux a broadcast from the origin back to MPEG-TS and stream //! it to the caller, so a plain SRT player (VLC, ffmpeg) can watch it. //! From c30f3cd0982a02b30f39ef58148acdf4a0478f19 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 29 Jun 2026 11:49:06 -0700 Subject: [PATCH 27/34] moq-mux: API cleanup before the semver bump (#1941) Co-authored-by: Claude Opus 4.8 --- rs/libmoq/src/publish.rs | 9 +- rs/moq-audio/src/codec.rs | 5 +- rs/moq-ffi/src/producer.rs | 9 +- rs/moq-mux/src/catalog/consumer.rs | 10 +- rs/moq-mux/src/catalog/hang/container.rs | 8 +- rs/moq-mux/src/catalog/hang/ext.rs | 5 +- rs/moq-mux/src/catalog/mod.rs | 7 +- rs/moq-mux/src/catalog/producer.rs | 38 +- rs/moq-mux/src/catalog/stream.rs | 16 +- rs/moq-mux/src/catalog/target.rs | 517 ------------------- rs/moq-mux/src/codec/h264/export.rs | 2 +- rs/moq-mux/src/codec/h265/export.rs | 2 +- rs/moq-mux/src/codec/opus/mod.rs | 38 +- rs/moq-mux/src/container/consumer.rs | 129 +++-- rs/moq-mux/src/container/flv/export_test.rs | 3 +- rs/moq-mux/src/container/flv/import_test.rs | 3 +- rs/moq-mux/src/container/fmp4/export.rs | 3 +- rs/moq-mux/src/container/fmp4/mod.rs | 7 +- rs/moq-mux/src/container/legacy/mod.rs | 14 +- rs/moq-mux/src/container/loc/mod.rs | 14 +- rs/moq-mux/src/container/mkv/export.rs | 5 +- rs/moq-mux/src/container/mkv/mod.rs | 4 + rs/moq-mux/src/container/mod.rs | 58 ++- rs/moq-mux/src/container/producer.rs | 2 +- rs/moq-mux/src/container/ts/catalog.rs | 44 +- rs/moq-mux/src/container/ts/export.rs | 12 +- rs/moq-mux/src/container/ts/export_test.rs | 2 +- rs/moq-mux/src/container/ts/import.rs | 50 +- rs/moq-mux/src/import/container.rs | 12 +- rs/moq-mux/src/import/track.rs | 535 ++++++++++---------- rs/moq-mux/src/lib.rs | 5 + 31 files changed, 507 insertions(+), 1061 deletions(-) delete mode 100644 rs/moq-mux/src/catalog/target.rs diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index 9b967024d..91da577d9 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -31,7 +31,10 @@ impl Publish { pub fn create(&mut self) -> Result { let mut broadcast = moq_net::Broadcast::new().produce(); // The untyped `Extra` extension lets catalog sections be set by name across the FFI boundary. - let catalog = moq_mux::catalog::Producer::new_extra(&mut broadcast)?; + let catalog = moq_mux::catalog::Producer::::with_catalog( + &mut broadcast, + moq_mux::catalog::hang::Catalog::default(), + )?; let id = self.broadcasts.insert((broadcast, catalog))?; Ok(id) @@ -148,7 +151,7 @@ impl Publish { /// The catalog is republished automatically. pub fn catalog_section(&mut self, broadcast: Id, name: &str, value: serde_json::Value) -> Result<(), Error> { let (_, catalog) = self.broadcasts.get_mut(broadcast).ok_or(Error::BroadcastNotFound)?; - catalog.set_section(name, value)?; + catalog.lock().set_section(name, value)?; Ok(()) } @@ -157,7 +160,7 @@ impl Publish { /// A no-op if absent. The catalog is republished automatically. pub fn catalog_section_remove(&mut self, broadcast: Id, name: &str) -> Result<(), Error> { let (_, catalog) = self.broadcasts.get_mut(broadcast).ok_or(Error::BroadcastNotFound)?; - catalog.remove_section(name); + catalog.lock().remove_section(name); Ok(()) } diff --git a/rs/moq-audio/src/codec.rs b/rs/moq-audio/src/codec.rs index 6007173b2..ecacb41f6 100644 --- a/rs/moq-audio/src/codec.rs +++ b/rs/moq-audio/src/codec.rs @@ -290,11 +290,14 @@ impl Encoder { /// hang catalog entry describing this encoder's output stream. pub fn catalog(&self) -> hang::catalog::AudioConfig { + // `codec_channels` is validated to mono/stereo at encoder construction, so the + // OpusHead (channel mapping family 0) always encodes. let head = moq_mux::codec::opus::Config { sample_rate: self.codec_rate, channel_count: self.codec_channels, } - .encode(); + .encode() + .expect("opus encoder channels validated to mono/stereo"); let mut config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, self.codec_rate, self.codec_channels); diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 3dd1d3918..271df3394 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -158,7 +158,10 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::RUNTIME.enter(); let mut broadcast = moq_net::Broadcast::new().produce(); // The untyped `Extra` extension lets catalog sections be set by name across the FFI boundary. - let catalog = moq_mux::catalog::Producer::new_extra(&mut broadcast)?; + let catalog = moq_mux::catalog::Producer::::with_catalog( + &mut broadcast, + moq_mux::catalog::hang::Catalog::default(), + )?; Ok(Arc::new(Self { state: std::sync::Mutex::new(Some(BroadcastProducer { broadcast, catalog })), })) @@ -300,7 +303,7 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::RUNTIME.enter(); let value: serde_json::Value = serde_json::from_str(&value).map_err(|err| MoqError::Json(err.to_string()))?; self.with_state(|state| { - state.catalog.set_section(name, value)?; + state.catalog.lock().set_section(name, value)?; Ok(()) }) } @@ -311,7 +314,7 @@ impl MoqBroadcastProducer { pub fn remove_catalog_section(&self, name: String) -> Result<(), MoqError> { let _guard = crate::ffi::RUNTIME.enter(); self.with_state(|state| { - state.catalog.remove_section(&name); + state.catalog.lock().remove_section(&name); Ok(()) }) } diff --git a/rs/moq-mux/src/catalog/consumer.rs b/rs/moq-mux/src/catalog/consumer.rs index 54932c8de..d07d0aeaf 100644 --- a/rs/moq-mux/src/catalog/consumer.rs +++ b/rs/moq-mux/src/catalog/consumer.rs @@ -13,10 +13,16 @@ use super::{CatalogFormat, Stream}; /// /// Both variants emit [`Catalog`](super::hang::Catalog); the MSF variant is /// media-only, so its extension is always the default. Wrap with -/// [`Filter`](super::Filter) / [`Target`](super::Target) to narrow the -/// rendition set before handing the stream to an exporter. +/// [`Filter`](super::Filter) to narrow the rendition set before handing the +/// stream to an exporter. +/// +/// The variants are an implementation detail: drive it through the [`Stream`] +/// trait rather than matching on them. New catalog encodings may be added. +#[non_exhaustive] pub enum Consumer { + #[doc(hidden)] Hang(super::hang::Consumer), + #[doc(hidden)] Msf(super::msf::Consumer), } diff --git a/rs/moq-mux/src/catalog/hang/container.rs b/rs/moq-mux/src/catalog/hang/container.rs index 06235dc4f..7ca3d30ae 100644 --- a/rs/moq-mux/src/catalog/hang/container.rs +++ b/rs/moq-mux/src/catalog/hang/container.rs @@ -1,6 +1,6 @@ use std::task::Poll; -use crate::container::{Container as ContainerTrait, Frame, fmp4, legacy, loc}; +use crate::container::{Container as ContainerTrait, Frame, Read, fmp4, legacy, loc}; /// Runtime-dispatched wire format for a track described by a hang catalog. /// @@ -42,11 +42,7 @@ impl ContainerTrait for Container { } } - fn poll_read( - &self, - group: &mut moq_net::GroupConsumer, - waiter: &kio::Waiter, - ) -> Poll>, Self::Error>> { + fn poll_read(&self, group: &mut moq_net::GroupConsumer, waiter: &kio::Waiter) -> Poll> { match self { Self::Legacy => legacy::Wire.poll_read(group, waiter), Self::Cmaf(cmaf) => cmaf.poll_read(group, waiter).map(|r| r.map_err(Into::into)), diff --git a/rs/moq-mux/src/catalog/hang/ext.rs b/rs/moq-mux/src/catalog/hang/ext.rs index d0842b2ae..0021d932d 100644 --- a/rs/moq-mux/src/catalog/hang/ext.rs +++ b/rs/moq-mux/src/catalog/hang/ext.rs @@ -193,7 +193,7 @@ mod test { #[test] fn untyped_extra_roundtrip() { let mut broadcast = moq_net::Broadcast::new().produce(); - let mut producer = crate::catalog::Producer::new_extra(&mut broadcast).unwrap(); + let mut producer = crate::catalog::Producer::::with_catalog(&mut broadcast, Catalog::default()).unwrap(); let mut consumer = producer.consume().unwrap(); // A media section (flat field) coexists with an arbitrary untyped application section. @@ -202,12 +202,13 @@ mod test { hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2), ); producer + .lock() .set_section("transcript", serde_json::json!({ "track": "transcript.json" })) .unwrap(); // Reserved media keys can't be smuggled in as application sections. assert!(matches!( - producer.set_section("video", serde_json::json!({})), + producer.lock().set_section("video", serde_json::json!({})), Err(crate::Error::ReservedSection(_)) )); diff --git a/rs/moq-mux/src/catalog/mod.rs b/rs/moq-mux/src/catalog/mod.rs index e0bb25aef..06c09c916 100644 --- a/rs/moq-mux/src/catalog/mod.rs +++ b/rs/moq-mux/src/catalog/mod.rs @@ -16,9 +16,8 @@ //! On the consume side, [`Consumer`] is the unified entry point: it //! subscribes to whichever catalog track `format` advertises and yields //! [`Catalog`](hang::Catalog) snapshots. Wrap it with [`Filter`] (hard -//! match on name / codec family) or [`Target`] (soft match picking one -//! rendition per axis) to narrow the set before handing it to an exporter; -//! both also implement [`Stream`] so they compose either direction. +//! match on name / codec family) to narrow the set before handing it to an +//! exporter; both also implement [`Stream`] so they compose either direction. pub mod hang; pub mod msf; @@ -28,7 +27,6 @@ mod filter; mod format; mod producer; mod stream; -mod target; mod tracks; pub use consumer::Consumer; @@ -36,5 +34,4 @@ pub use filter::{Filter, FilterAudio, FilterVideo}; pub use format::*; pub use producer::{Guard, Producer}; pub use stream::Stream; -pub use target::{Target, TargetAudio, TargetVideo}; pub use tracks::{AudioTrack, VideoTrack}; diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index f6b46d803..6553704d6 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -47,41 +47,16 @@ impl Clone for Producer { } impl Producer<()> { - /// Create a new catalog producer with the default (empty) catalog. + /// Create a new media-only catalog producer with the default (empty) catalog. /// - /// To publish an extended catalog, use [`with_catalog`](Self::with_catalog) with a `Catalog`. + /// For an extended catalog, use [`with_catalog`](Self::with_catalog) with a + /// `Catalog` (e.g. the untyped [`Extra`] for the by-name / FFI path). Set + /// application sections through [`lock`](Self::lock). pub fn new(broadcast: &mut moq_net::BroadcastProducer) -> Result { Self::with_catalog(broadcast, Catalog::default()) } } -impl Producer { - /// Create a catalog producer carrying the untyped [`Extra`] extension, so application - /// sections can be set later via [`set_section`](Self::set_section). This is the entry - /// point for callers that work with sections by name (e.g. the FFI boundary); for a typed - /// extension use [`with_catalog`](Self::with_catalog) with a `Catalog`. - pub fn new_extra(broadcast: &mut moq_net::BroadcastProducer) -> Result { - Self::with_catalog(broadcast, Catalog::default()) - } - - /// Set (or replace) a top-level application catalog section, publishing the updated catalog. - /// - /// `value` is any JSON document (object, array, string, ...). Errors if `name` collides with a - /// reserved media section (`video`/`audio`). This is the untyped counterpart to mutating a - /// typed extension through [`lock`](Self::lock), used where section names aren't known at - /// compile time (e.g. across the FFI boundary). - pub fn set_section(&mut self, name: impl Into, value: serde_json::Value) -> crate::Result<()> { - self.lock().set_section(name, value) - } - - /// Remove a top-level application catalog section, publishing the updated catalog if it existed. - /// - /// Returns the section's previous value, or `None` if it was absent. - pub fn remove_section(&mut self, name: &str) -> Option { - self.lock().remove_section(name) - } -} - impl Producer { /// Create a new catalog producer with the given initial catalog. pub fn with_catalog( @@ -164,11 +139,6 @@ impl Producer { Ok(Consumer::new(self.hang.consume())) } - /// Create a consumer for the DEFLATE-compressed (`catalog.json.z`) catalog track. - pub fn consume_compressed(&self) -> Result, moq_net::Error> { - Ok(Consumer::compressed(self.hangz.consume())) - } - /// Finish publishing to this catalog. pub fn finish(&mut self) -> crate::Result<()> { self.hang.finish()?; diff --git a/rs/moq-mux/src/catalog/stream.rs b/rs/moq-mux/src/catalog/stream.rs index 28ac16ec5..10690bca1 100644 --- a/rs/moq-mux/src/catalog/stream.rs +++ b/rs/moq-mux/src/catalog/stream.rs @@ -2,9 +2,8 @@ //! //! [`Stream`] yields a sequence of [`Catalog`](super::hang::Catalog) snapshots. Both the //! raw [`Consumer`](super::Consumer) and the rendition-selecting -//! [`Filter`](super::Filter) / [`Target`](super::Target) wrappers implement -//! it, so exporters can be written against the trait and the caller picks -//! the selection policy. +//! [`Filter`](super::Filter) wrapper implement it, so exporters can be written +//! against the trait and the caller picks the selection policy. //! //! The yielded catalog carries the application extension `E` (defaulting to //! `()` for media-only catalogs) via the [`Ext`](Stream::Ext) associated type, @@ -12,8 +11,8 @@ use std::task::Poll; +use super::Filter; use super::hang::{Catalog, CatalogExt}; -use super::{Filter, Target}; /// A stream of catalog snapshots. /// @@ -45,13 +44,4 @@ pub trait Stream: Send + 'static { { Filter::new(self) } - - /// Wrap this stream in a [`Target`] that reduces each axis to at most - /// one rendition by soft-matching against width / height / bitrate. - fn target(self) -> Target - where - Self: Sized, - { - Target::new(self) - } } diff --git a/rs/moq-mux/src/catalog/target.rs b/rs/moq-mux/src/catalog/target.rs deleted file mode 100644 index 5356660b4..000000000 --- a/rs/moq-mux/src/catalog/target.rs +++ /dev/null @@ -1,517 +0,0 @@ -//! Soft-match rendition target. -//! -//! [`Target`] wraps any [`Stream`] and reduces each axis (video / audio) to at -//! most one rendition by ranking the input against constraints like maximum -//! width, height, pixels, or bitrate. The ranking algorithm is a Rust port of -//! [js/watch's `#select`](js/watch/src/video/source.ts). - -use std::collections::BTreeMap; -use std::task::Poll; - -use hang::catalog::{AudioConfig, VideoConfig}; - -use super::Stream; -use super::hang::{Catalog, CatalogExt}; - -/// Soft-match constraints for the video rendition. -/// -/// Each `Option` is a *maximum* the selection will try to stay under. When a -/// rendition fits every active maximum, the largest such rendition wins; if -/// nothing fits, the algorithm degrades to the smallest over-budget rendition -/// (per constraint) and intersects across constraints. -#[derive(Debug, Default, Clone)] -pub struct TargetVideo { - pub width: Option, - pub height: Option, - pub pixels: Option, - pub bitrate: Option, -} - -/// Soft-match constraints for the audio rendition. -#[derive(Debug, Default, Clone)] -pub struct TargetAudio { - pub bitrate: Option, -} - -/// Shared state behind a [`Target`]. -/// -/// `epoch` advances on every setter so [`Target::poll_next`] can tell whether -/// the criteria changed since the last emit without diffing the structs. -#[derive(Debug, Default, Clone)] -struct TargetState { - video: Option, - audio: Option, - epoch: u64, -} - -/// A [`Stream`] that picks one rendition per axis from the inner snapshot. -/// -/// Selection criteria live behind a [`kio::Producer`], so calls to -/// [`set_video`](Self::set_video) / [`set_audio`](Self::set_audio) wake any -/// pending `poll_next` instead of silently waiting for the next upstream -/// snapshot. That makes the type usable as the foothold for bandwidth-driven -/// ABR retargeting. -pub struct Target { - inner: S, - state: kio::Producer, - state_consumer: kio::Consumer, - /// Last raw snapshot from `inner`, retained so a target change between - /// snapshots can be re-applied without polling upstream. - last_input: Option>, - /// Epoch we already emitted against. If `state.epoch` advances past this - /// while `last_input` is `Some`, the next poll re-emits. - last_epoch: u64, - /// True once `inner` has handed us a snapshot we haven't emitted yet. - fresh_input: bool, -} - -impl Target { - pub fn new(inner: S) -> Self { - let state = kio::Producer::new(TargetState::default()); - let state_consumer = state.consume(); - Self { - inner, - state, - state_consumer, - last_input: None, - last_epoch: 0, - fresh_input: false, - } - } - - /// Set or clear the video target. Pass `None` to keep every rendition. - pub fn set_video(&mut self, target: impl Into>) { - self.update(|s| s.video = target.into()); - } - - /// Set or clear the audio target. Pass `None` to keep every rendition. - pub fn set_audio(&mut self, target: impl Into>) { - self.update(|s| s.audio = target.into()); - } - - fn update(&self, f: impl FnOnce(&mut TargetState)) { - // `write()` only errors when the producer is closed, which can't happen - // while `self` holds the only producer handle. - let Ok(mut state) = self.state.write() else { - return; - }; - f(&mut state); - state.epoch = state.epoch.wrapping_add(1); - // Mut::drop wakes the paired consumer waiters here. - } -} - -impl Stream for Target { - type Ext = S::Ext; - - fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { - // Drain inner: the latest snapshot wins. `poll_next` registers the - // waiter on its own Pending branch. - let inner_eof = loop { - match self.inner.poll_next(waiter)? { - Poll::Ready(Some(snapshot)) => { - self.last_input = Some(snapshot); - self.fresh_input = true; - } - Poll::Ready(None) => break true, - Poll::Pending => break false, - } - }; - - // Snapshot the fields the inner closure needs so it can borrow them - // without colliding with the `&self.state_consumer` receiver. - let last_epoch = self.last_epoch; - let fresh_input = self.fresh_input; - let last_input = self.last_input.clone(); - - let polled = self.state_consumer.poll(waiter, |state| { - let target_changed = state.epoch != last_epoch; - if !fresh_input && !target_changed { - // Nothing new from inner and nothing new from caller: register - // the waiter on this consumer so the next setter wakes us. - return Poll::Pending; - } - let Some(input) = last_input.clone() else { - // Caller already retargeted, but no upstream snapshot yet to apply. - return Poll::Pending; - }; - let emit = apply(input, state.video.as_ref(), state.audio.as_ref()); - Poll::Ready((emit, state.epoch)) - }); - - match polled { - Poll::Ready(Ok((emit, epoch))) => { - self.last_epoch = epoch; - self.fresh_input = false; - // End with upstream: if this is the final snapshot (inner already EOF'd), - // drop the retained input so a later retarget can't revive the stream after - // it has emitted its last value. - if inner_eof { - self.last_input = None; - } - Poll::Ready(Ok(Some(emit))) - } - Poll::Ready(Err(_)) => { - // Producer dropped (impossible while Self holds it); treat as EOF. - Poll::Ready(Ok(None)) - } - Poll::Pending => { - // EOF is terminal: once `inner` is exhausted and there's nothing fresh to - // emit, finish and drop the retained input so a post-EOF retarget can't make - // the closure emit again (a still-pending snapshot returns Ready above). - if inner_eof { - self.last_input = None; - Poll::Ready(Ok(None)) - } else { - Poll::Pending - } - } - } - } -} - -/// Apply the active video / audio targets to a raw snapshot, narrowing each -/// axis to at most one rendition. Axes with no target pass through unchanged. -fn apply( - mut catalog: Catalog, - video: Option<&TargetVideo>, - audio: Option<&TargetAudio>, -) -> Catalog { - if let Some(target) = video { - if let Some(name) = select_video(&catalog.video.renditions, target) { - let mut kept = BTreeMap::new(); - if let Some(config) = catalog.video.renditions.remove(&name) { - kept.insert(name, config); - } - catalog.video.renditions = kept; - } else { - catalog.video.renditions.clear(); - } - } - - if let Some(target) = audio { - if let Some(name) = select_audio(&catalog.audio.renditions, target) { - let mut kept = BTreeMap::new(); - if let Some(config) = catalog.audio.renditions.remove(&name) { - kept.insert(name, config); - } - catalog.audio.renditions = kept; - } else { - catalog.audio.renditions.clear(); - } - } - - catalog -} - -/// Run all active video rankings and return the highest-ranked rendition -/// present in every ranking, or `None` if the intersection is empty. -fn select_video(renditions: &BTreeMap, target: &TargetVideo) -> Option { - if renditions.is_empty() { - return None; - } - if renditions.len() == 1 { - return renditions.keys().next().cloned(); - } - - let mut rankings: Vec> = Vec::new(); - if let Some(max) = target.pixels { - rankings.push(by_pixels(renditions, max)); - } - if target.width.is_some() || target.height.is_some() { - rankings.push(by_dimensions(renditions, target.width, target.height)); - } - if let Some(max) = target.bitrate { - rankings.push(by_video_bitrate(renditions, max)); - } - - if rankings.is_empty() { - return Some(best_video(renditions)); - } - - intersect_rankings(rankings) -} - -fn select_audio(renditions: &BTreeMap, target: &TargetAudio) -> Option { - if renditions.is_empty() { - return None; - } - if renditions.len() == 1 { - return renditions.keys().next().cloned(); - } - - let mut rankings: Vec> = Vec::new(); - if let Some(max) = target.bitrate { - rankings.push(by_audio_bitrate(renditions, max)); - } - - if rankings.is_empty() { - return Some(best_audio(renditions)); - } - - intersect_rankings(rankings) -} - -/// Pick the first name from `rankings[0]` that appears in every other ranking. -fn intersect_rankings(rankings: Vec>) -> Option { - use std::collections::HashSet; - let sets: Vec> = rankings.iter().map(|r| r.iter().collect()).collect(); - for name in &rankings[0] { - if sets.iter().all(|s| s.contains(name)) { - return Some(name.clone()); - } - } - tracing::warn!("conflicting rendition targets, no rendition satisfies all criteria"); - None -} - -/// Rank by area, largest-first within budget; fall back to single smallest -/// over-budget if nothing fits. Renditions without resolution metadata are -/// returned unranked when no rendition has any metadata at all (mirrors the JS). -fn by_pixels(renditions: &BTreeMap, max: u32) -> Vec { - let mut within: Vec<(String, u32)> = Vec::new(); - let mut rest: Vec<(String, u32)> = Vec::new(); - - for (name, config) in renditions { - if let (Some(w), Some(h)) = (config.coded_width, config.coded_height) { - let size = w.saturating_mul(h); - if size <= max { - within.push((name.clone(), size)); - } else { - rest.push((name.clone(), size)); - } - } - } - - within.sort_by_key(|b| std::cmp::Reverse(b.1)); - if !within.is_empty() { - return within.into_iter().map(|(n, _)| n).collect(); - } - - rest.sort_by_key(|a| a.1); - if let Some(smallest) = rest.into_iter().next() { - return vec![smallest.0]; - } - - renditions.keys().cloned().collect() -} - -fn by_dimensions(renditions: &BTreeMap, width: Option, height: Option) -> Vec { - let mut within: Vec<(String, u32)> = Vec::new(); - let mut rest: Vec<(String, u32)> = Vec::new(); - - for (name, config) in renditions { - let (Some(w), Some(h)) = (config.coded_width, config.coded_height) else { - continue; - }; - let size = w.saturating_mul(h); - let fits_w = width.is_none_or(|cap| w <= cap); - let fits_h = height.is_none_or(|cap| h <= cap); - if fits_w && fits_h { - within.push((name.clone(), size)); - } else { - rest.push((name.clone(), size)); - } - } - - within.sort_by_key(|b| std::cmp::Reverse(b.1)); - if !within.is_empty() { - return within.into_iter().map(|(n, _)| n).collect(); - } - - rest.sort_by_key(|a| a.1); - if let Some(smallest) = rest.into_iter().next() { - return vec![smallest.0]; - } - - renditions.keys().cloned().collect() -} - -fn by_video_bitrate(renditions: &BTreeMap, max: u64) -> Vec { - let mut within: Vec<(String, u64)> = Vec::new(); - let mut rest: Vec<(String, u64)> = Vec::new(); - for (name, config) in renditions { - if let Some(b) = config.bitrate { - if b <= max { - within.push((name.clone(), b)); - } else { - rest.push((name.clone(), b)); - } - } - } - within.sort_by_key(|b| std::cmp::Reverse(b.1)); - if !within.is_empty() { - return within.into_iter().map(|(n, _)| n).collect(); - } - rest.sort_by_key(|a| a.1); - if let Some(smallest) = rest.into_iter().next() { - return vec![smallest.0]; - } - renditions.keys().cloned().collect() -} - -fn by_audio_bitrate(renditions: &BTreeMap, max: u64) -> Vec { - let mut within: Vec<(String, u64)> = Vec::new(); - let mut rest: Vec<(String, u64)> = Vec::new(); - for (name, config) in renditions { - if let Some(b) = config.bitrate { - if b <= max { - within.push((name.clone(), b)); - } else { - rest.push((name.clone(), b)); - } - } - } - within.sort_by_key(|b| std::cmp::Reverse(b.1)); - if !within.is_empty() { - return within.into_iter().map(|(n, _)| n).collect(); - } - rest.sort_by_key(|a| a.1); - if let Some(smallest) = rest.into_iter().next() { - return vec![smallest.0]; - } - renditions.keys().cloned().collect() -} - -/// With no constraints, prefer the largest resolution then the highest bitrate. -fn best_video(renditions: &BTreeMap) -> String { - renditions - .iter() - .max_by_key(|(_, c)| { - let area = c.coded_width.unwrap_or(0).saturating_mul(c.coded_height.unwrap_or(0)) as u64; - (area, c.bitrate.unwrap_or(0)) - }) - .map(|(n, _)| n.clone()) - .expect("renditions non-empty checked by caller") -} - -fn best_audio(renditions: &BTreeMap) -> String { - renditions - .iter() - .max_by_key(|(_, c)| c.bitrate.unwrap_or(0)) - .map(|(n, _)| n.clone()) - .expect("renditions non-empty checked by caller") -} - -#[cfg(test)] -mod test { - use std::collections::BTreeMap; - - use hang::catalog::{Container, H264, VideoConfig}; - - use super::*; - - /// A one-shot stream: yields its snapshot once, then EOF. - struct Once(Option); - - impl Stream for Once { - type Ext = (); - - fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { - Poll::Ready(Ok(self.0.take())) - } - } - - /// Once upstream ends and the final selected snapshot is emitted, the stream ends - /// rather than parking forever waiting for a post-EOF retarget. - #[test] - fn ends_after_upstream_eof() { - let mut catalog = Catalog::default(); - catalog.video.renditions = BTreeMap::from_iter(vec![vid("only", 640, 360, 500_000)]); - - let mut t = Target::new(Once(Some(catalog))); - assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(Some(_))))); - assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); - - // EOF is terminal: a retarget after the end must not revive the stream. - t.set_video(TargetVideo { - width: Some(320), - ..Default::default() - }); - assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); - } - - fn vid(name: &str, w: u32, h: u32, bitrate: u64) -> (String, VideoConfig) { - let mut config = VideoConfig::new(H264 { - profile: 0x42, - constraints: 0, - level: 0x1e, - inline: false, - }); - config.coded_width = Some(w); - config.coded_height = Some(h); - config.bitrate = Some(bitrate); - config.framerate = Some(30.0); - config.container = Container::Legacy; - (name.to_string(), config) - } - - fn map(items: Vec<(String, VideoConfig)>) -> BTreeMap { - BTreeMap::from_iter(items) - } - - #[test] - fn pick_largest_under_width_cap() { - let renditions = map(vec![ - vid("sd", 640, 360, 500_000), - vid("hd", 1280, 720, 2_500_000), - vid("fhd", 1920, 1080, 6_000_000), - ]); - let target = TargetVideo { - width: Some(1280), - ..Default::default() - }; - assert_eq!(select_video(&renditions, &target).as_deref(), Some("hd")); - } - - #[test] - fn pick_largest_under_bitrate_cap() { - let renditions = map(vec![ - vid("sd", 640, 360, 500_000), - vid("hd", 1280, 720, 2_500_000), - vid("fhd", 1920, 1080, 6_000_000), - ]); - let target = TargetVideo { - bitrate: Some(3_000_000), - ..Default::default() - }; - assert_eq!(select_video(&renditions, &target).as_deref(), Some("hd")); - } - - #[test] - fn degrade_to_smallest_over_budget() { - let renditions = map(vec![vid("hd", 1280, 720, 2_500_000), vid("fhd", 1920, 1080, 6_000_000)]); - let target = TargetVideo { - bitrate: Some(100_000), - ..Default::default() - }; - assert_eq!(select_video(&renditions, &target).as_deref(), Some("hd")); - } - - #[test] - fn no_constraints_picks_largest() { - let renditions = map(vec![ - vid("sd", 640, 360, 500_000), - vid("hd", 1280, 720, 2_500_000), - vid("fhd", 1920, 1080, 6_000_000), - ]); - let target = TargetVideo::default(); - assert_eq!(select_video(&renditions, &target).as_deref(), Some("fhd")); - } - - #[test] - fn width_and_bitrate_intersect() { - let renditions = map(vec![ - vid("sd", 640, 360, 500_000), - vid("hd", 1280, 720, 2_500_000), - vid("fhd", 1920, 1080, 6_000_000), - ]); - let target = TargetVideo { - width: Some(1920), - bitrate: Some(1_000_000), - ..Default::default() - }; - // width allows all, bitrate allows only sd. - assert_eq!(select_video(&renditions, &target).as_deref(), Some("sd")); - } -} diff --git a/rs/moq-mux/src/codec/h264/export.rs b/rs/moq-mux/src/codec/h264/export.rs index 08e7bc48b..3929c68e4 100644 --- a/rs/moq-mux/src/codec/h264/export.rs +++ b/rs/moq-mux/src/codec/h264/export.rs @@ -133,7 +133,7 @@ impl Export { tracing::warn!( count = picked.len(), "multiple H.264 renditions in catalog snapshot; using the first by name. \ - Narrow with catalog::Target to pick one explicitly." + Narrow with catalog::Filter to pick one explicitly." ); } diff --git a/rs/moq-mux/src/codec/h265/export.rs b/rs/moq-mux/src/codec/h265/export.rs index 308aadf87..be877d8ab 100644 --- a/rs/moq-mux/src/codec/h265/export.rs +++ b/rs/moq-mux/src/codec/h265/export.rs @@ -124,7 +124,7 @@ impl Export { tracing::warn!( count = picked.len(), "multiple H.265 renditions in catalog snapshot; using the first by name. \ - Narrow with catalog::Target to pick one explicitly." + Narrow with catalog::Filter to pick one explicitly." ); } diff --git a/rs/moq-mux/src/codec/opus/mod.rs b/rs/moq-mux/src/codec/opus/mod.rs index 035a397a0..08c2f3e18 100644 --- a/rs/moq-mux/src/codec/opus/mod.rs +++ b/rs/moq-mux/src/codec/opus/mod.rs @@ -15,11 +15,18 @@ const OPUS_HEAD: u64 = u64::from_be_bytes(*b"OpusHead"); #[derive(Debug, Clone, thiserror::Error)] #[non_exhaustive] pub enum Error { + /// The OpusHead packet was shorter than the 19-byte minimum (RFC 7845 §5.1). #[error("OpusHead must be at least 19 bytes")] HeadTooShort, + /// The packet did not start with the `OpusHead` magic signature. #[error("invalid OpusHead signature")] InvalidSignature, + + /// [`Config::encode`] was asked to emit an OpusHead for a channel count other + /// than mono or stereo; channel mapping family 0 only covers 1 or 2 channels. + #[error("channel mapping family 0 only supports mono/stereo (got {0} channels)")] + UnsupportedChannelCount(u32), } pub type Result = std::result::Result; @@ -64,15 +71,14 @@ impl Config { /// Encode the minimal OpusHead packet (19 bytes; channel mapping family /// 0, zero pre-skip and gain). /// - /// Panics if `channel_count > 2` — mapping family 0 is only defined for - /// mono/stereo per RFC 7845 §5.1. Multi-channel streams need family 1 - /// with a channel mapping table, which this helper does not emit. - pub fn encode(&self) -> Bytes { - assert!( - self.channel_count <= 2, - "OpusHead mapping family 0 only supports mono/stereo (got channel_count={})", - self.channel_count - ); + /// Errors with [`Error::UnsupportedChannelCount`] unless `channel_count` is 1 + /// or 2 — mapping family 0 is only defined for mono/stereo per RFC 7845 §5.1. + /// Multi-channel streams need family 1 with a channel mapping table, which + /// this helper does not emit. + pub fn encode(&self) -> Result { + if !(1..=2).contains(&self.channel_count) { + return Err(Error::UnsupportedChannelCount(self.channel_count)); + } let mut head = Vec::with_capacity(19); head.extend_from_slice(b"OpusHead"); head.push(1); // version @@ -81,7 +87,7 @@ impl Config { head.extend_from_slice(&self.sample_rate.to_le_bytes()); head.extend_from_slice(&0i16.to_le_bytes()); // output gain head.push(0); // channel mapping family (0 = mono/stereo) - Bytes::from(head) + Ok(Bytes::from(head)) } } @@ -95,7 +101,7 @@ mod tests { sample_rate: 48000, channel_count: 2, }; - let encoded = cfg.encode(); + let encoded = cfg.encode().unwrap(); assert_eq!(encoded.len(), 19); let parsed = Config::parse(&mut encoded.as_ref()).unwrap(); assert_eq!(parsed.sample_rate, 48000); @@ -109,18 +115,20 @@ mod tests { channel_count: 1, } .encode() + .unwrap() .to_vec(); bytes[0] = b'X'; assert!(Config::parse(&mut bytes.as_slice()).is_err()); } #[test] - #[should_panic(expected = "mapping family 0")] - fn encode_panics_for_multichannel() { - Config { + fn encode_rejects_multichannel() { + let err = Config { sample_rate: 48000, channel_count: 6, } - .encode(); + .encode() + .unwrap_err(); + assert!(matches!(err, Error::UnsupportedChannelCount(6))); } } diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 16086489a..48ef24b95 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use std::task::{Poll, ready}; use super::Timestamp; -use super::{Container, Frame}; +use super::{Container, Frame, Read}; /// Decode a moq-lite track into a stream of media [`Frame`]s in latency-bounded /// presentation order. @@ -28,16 +28,13 @@ use super::{Container, Frame}; /// group's first timestamp has nothing left worth waiting for. Containers without a /// duration report zero, which disables this check and falls back to the latency budget. /// -/// Set the latency with [`with_latency`](Self::with_latency) (builder) or -/// [`set_latency`](Self::set_latency) (mid-stream). +/// Set the latency with [`with_latency`](Self::with_latency). /// /// ## Timeline rewinds /// /// If a newer group's timestamps jump backwards past the live edge, the publisher is /// reneging the buffered tail (e.g. a voice agent interrupted mid-utterance). The consumer -/// drops the reneged groups, resumes at the rewound timeline, and bumps -/// [`discontinuity`](Self::discontinuity) so downstream consumers can flush their own -/// buffers. This is always on. +/// drops the reneged groups and resumes at the rewound timeline. This is always on. pub struct Consumer { track: moq_net::TrackConsumer, @@ -147,18 +144,6 @@ impl Consumer { self } - /// A counter that increments each time the consumer detects a timeline rewind and drops - /// the reneged buffer. - /// - /// When a newer group's timestamps jump backwards past the live edge, the publisher is - /// reneging everything buffered after that point (e.g. a voice agent interrupted - /// mid-utterance). Downstream consumers should compare this across reads and, when it - /// changes, flush any media still queued in their decoder or render buffers. The frame - /// returned by the read that bumps it is the first of the new timeline. - pub fn discontinuity(&self) -> u64 { - self.rewind.discontinuity - } - /// Read the next frame from the track. /// /// This method handles timestamp decoding, group ordering, and latency management @@ -472,11 +457,6 @@ impl Consumer { Ok(()) } - /// Set the maximum latency tolerance. - pub fn set_latency(&mut self, latency: std::time::Duration) { - self.latency = latency; - } - /// Wait until the track is closed. pub async fn closed(&self) -> Result<(), F::Error> { Ok(self.track.closed().await?) @@ -532,46 +512,53 @@ impl GroupBuffer { } } - // Add one more frame to the buffer if possible. + // Add one (or one fragment's worth) more frames to the buffer if possible. // // Returns false if the group is finished. fn buffer_once(&mut self, waiter: &kio::Waiter, format: &F) -> Poll> { - let Some(frames) = ready!(format.poll_read(&mut self.group, waiter)?) else { - return Poll::Ready(Ok(false)); - }; - - for mut frame in frames { - self.min_timestamp = Some(match self.min_timestamp { - Some(existing) => existing.min(frame.timestamp), - None => frame.timestamp, - }); - - self.max_timestamp = Some(match self.max_timestamp { - Some(existing) => existing.max(frame.timestamp), - None => frame.timestamp, - }); - - // Furthest presentation point, in wall-clock terms so timestamp and - // duration can be at different scales without extra conversions. A frame - // with no duration contributes only its timestamp. - let duration = frame.duration.map(std::time::Duration::from).unwrap_or_default(); - let end = std::time::Duration::from(frame.timestamp) + duration; - self.max_end = Some(match self.max_end { - Some(existing) => existing.max(end), - None => end, - }); - - // First frame of a group is always a keyframe by protocol invariant; trust - // the container's flag otherwise so CMAF mid-group keyframes survive. - frame.keyframe = frame.keyframe || self.index == 0; - self.index += 1; - - self.buffered.push_back(frame); + match ready!(format.poll_read(&mut self.group, waiter)?) { + Read::Done => return Poll::Ready(Ok(false)), + Read::Frame(frame) => self.ingest(frame), + Read::Fragment(frames) => { + for frame in frames { + self.ingest(frame); + } + } } Poll::Ready(Ok(true)) } + // Track timestamp bounds, stamp the keyframe flag, and queue one decoded frame. + fn ingest(&mut self, mut frame: Frame) { + self.min_timestamp = Some(match self.min_timestamp { + Some(existing) => existing.min(frame.timestamp), + None => frame.timestamp, + }); + + self.max_timestamp = Some(match self.max_timestamp { + Some(existing) => existing.max(frame.timestamp), + None => frame.timestamp, + }); + + // Furthest presentation point, in wall-clock terms so timestamp and + // duration can be at different scales without extra conversions. A frame + // with no duration contributes only its timestamp. + let duration = frame.duration.map(std::time::Duration::from).unwrap_or_default(); + let end = std::time::Duration::from(frame.timestamp) + duration; + self.max_end = Some(match self.max_end { + Some(existing) => existing.max(end), + None => end, + }); + + // First frame of a group is always a keyframe by protocol invariant; trust + // the container's flag otherwise so CMAF mid-group keyframes survive. + frame.keyframe = frame.keyframe || self.index == 0; + self.index += 1; + + self.buffered.push_back(frame); + } + fn buffer_one(&mut self, waiter: &kio::Waiter, format: &F) -> Poll> { loop { if !self.buffered.is_empty() { @@ -580,7 +567,7 @@ impl GroupBuffer { if !ready!(self.buffer_once(waiter, format)?) { return Poll::Ready(Ok(false)); } - // poll_read returned Some(vec![]) — loop and try again + // poll_read returned an empty Read::Fragment — loop and try again } } @@ -697,23 +684,23 @@ mod tests { &self, group: &mut moq_net::GroupConsumer, waiter: &kio::Waiter, - ) -> Poll>, Self::Error>> { + ) -> Poll> { use bytes::Buf; let Some(mut data) = ready!(group.poll_read_frame(waiter)?) else { - return Poll::Ready(Ok(None)); + return Poll::Ready(Ok(Read::Done)); }; let timestamp = ts(data.get_u64_le()); let duration = ts(data.get_u64_le()); let payload = data.copy_to_bytes(data.remaining()); - Poll::Ready(Ok(Some(vec![Frame { + Poll::Ready(Ok(Read::Frame(Frame { timestamp, payload, keyframe: false, duration: Some(duration), - }]))) + }))) } } @@ -992,7 +979,7 @@ mod tests { micros.contains(&1_000) && micros.contains(&2_000) && micros.contains(&3_000), "out-of-order new-epoch groups kept, got {micros:?}" ); - assert_eq!(consumer.discontinuity(), 1, "one rewind detected"); + assert_eq!(consumer.rewind.discontinuity, 1, "one rewind detected"); finisher.await.expect("finisher task panicked"); } @@ -1020,8 +1007,7 @@ mod tests { let micros: Vec = frames.iter().map(|f| f.timestamp.as_micros()).collect(); assert_eq!( - consumer.discontinuity(), - 1, + consumer.rewind.discontinuity, 1, "rewind detected behind a forward newest group" ); assert!(micros.contains(&50_000), "resumed at the rewound group, got {micros:?}"); @@ -1056,7 +1042,7 @@ mod tests { // We play forward until the live edge passes the rewind point (through 100 ms), then // the rewind drops the buffered-ahead groups (200/300/400 ms) and resumes at group 5. assert_eq!(timestamps, vec![ts(0), ts(100_000), ts(0), ts(20_000)]); - assert_eq!(consumer.discontinuity(), 1); + assert_eq!(consumer.rewind.discontinuity, 1); } /// Rewind detection is always on: a backwards group timestamp resets the buffer with no @@ -1076,7 +1062,10 @@ mod tests { let timestamps: Vec<_> = frames.iter().map(|f| f.timestamp).collect(); assert_eq!(timestamps, vec![ts(0), ts(500_000), ts(0)]); - assert_eq!(consumer.discontinuity(), 1, "the backwards group triggered a reset"); + assert_eq!( + consumer.rewind.discontinuity, 1, + "the backwards group triggered a reset" + ); } // ---- Group Ordering ---- @@ -1347,21 +1336,21 @@ mod tests { &self, group: &mut moq_net::GroupConsumer, waiter: &kio::Waiter, - ) -> Poll>, Self::Error>> { + ) -> Poll> { use bytes::Buf; let Some(mut data) = ready!(group.poll_read_frame(waiter)?) else { - return Poll::Ready(Ok(None)); + return Poll::Ready(Ok(Read::Done)); }; if data.as_ref() == b"FAIL" { return Poll::Ready(Err(crate::Error::UnknownFormat("malformed payload".into()))); } - Poll::Ready(Ok(Some(vec![Frame { + Poll::Ready(Ok(Read::Frame(Frame { timestamp: ts(data.get_u64_le()), payload: Bytes::new(), keyframe: false, duration: None, - }]))) + }))) } } @@ -1529,7 +1518,7 @@ mod tests { let frame = consumer.read().await.unwrap().unwrap(); assert_eq!(frame.timestamp, ts(0)); - consumer.set_latency(Duration::from_millis(100)); + consumer.latency = Duration::from_millis(100); assert!(consumer.read().await.unwrap().is_none()); } diff --git a/rs/moq-mux/src/container/flv/export_test.rs b/rs/moq-mux/src/container/flv/export_test.rs index 7b24f00d3..4226a1e79 100644 --- a/rs/moq-mux/src/container/flv/export_test.rs +++ b/rs/moq-mux/src/container/flv/export_test.rs @@ -194,7 +194,8 @@ fn synth_enhanced_flv() -> Vec { sample_rate: 48000, channel_count: 2, } - .encode(); + .encode() + .unwrap(); let mut out = Vec::new(); out.extend_from_slice(b"FLV"); diff --git a/rs/moq-mux/src/container/flv/import_test.rs b/rs/moq-mux/src/container/flv/import_test.rs index 252e0b7b0..a3c4089ef 100644 --- a/rs/moq-mux/src/container/flv/import_test.rs +++ b/rs/moq-mux/src/container/flv/import_test.rs @@ -188,7 +188,8 @@ async fn import_enhanced_opus() { sample_rate: 48000, channel_count: 2, } - .encode(); + .encode() + .unwrap(); let mut out = Vec::new(); out.extend_from_slice(b"FLV"); diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index f72a4e98a..ba98f2b2e 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -104,8 +104,7 @@ impl Export { /// /// `catalog` is any [`Stream`] of catalog snapshots, typically a /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or wrapped in - /// [`catalog::Filter`](crate::catalog::Filter) / - /// [`catalog::Target`](crate::catalog::Target) to narrow the rendition set. + /// [`catalog::Filter`](crate::catalog::Filter) to narrow the rendition set. pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { Self { broadcast, diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 5be7927bf..20b4f75b9 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -192,15 +192,16 @@ impl Container for Wire { &self, group: &mut moq_net::GroupConsumer, waiter: &kio::Waiter, - ) -> Poll>, Self::Error>> { + ) -> Poll> { use std::task::ready; let Some(data) = ready!(group.poll_read_frame(waiter)?) else { - return Poll::Ready(Ok(None)); + return Poll::Ready(Ok(crate::container::Read::Done)); }; let timescale = self.trak.mdia.mdhd.timescale as u64; - Poll::Ready(Ok(Some(decode(data, timescale)?))) + // A CMAF moof+mdat fragment carries every sample for the fragment. + Poll::Ready(Ok(crate::container::Read::Fragment(decode(data, timescale)?))) } } diff --git a/rs/moq-mux/src/container/legacy/mod.rs b/rs/moq-mux/src/container/legacy/mod.rs index 5f77ca6b1..573072efe 100644 --- a/rs/moq-mux/src/container/legacy/mod.rs +++ b/rs/moq-mux/src/container/legacy/mod.rs @@ -8,7 +8,7 @@ use std::task::Poll; use bytes::Buf; -use crate::container::{Container, Frame}; +use crate::container::{Container, Frame, Read}; /// Hang Legacy wire format. Stateless; one instance serves every track. #[derive(Default)] @@ -28,21 +28,17 @@ impl Container for Wire { Ok(()) } - fn poll_read( - &self, - group: &mut moq_net::GroupConsumer, - waiter: &kio::Waiter, - ) -> Poll>, Self::Error>> { + fn poll_read(&self, group: &mut moq_net::GroupConsumer, waiter: &kio::Waiter) -> Poll> { use std::task::ready; let Some(data) = ready!(group.poll_read_frame(waiter).map_err(hang::Error::from)?) else { - return Poll::Ready(Ok(None)); + return Poll::Ready(Ok(Read::Done)); }; let mut hang_frame = hang::container::Frame::decode(data)?; let payload = hang_frame.payload.copy_to_bytes(hang_frame.payload.remaining()); - Poll::Ready(Ok(Some(vec![Frame { + Poll::Ready(Ok(Read::Frame(Frame { timestamp: hang_frame.timestamp, payload, // Legacy doesn't carry the keyframe bit on the wire; the @@ -50,6 +46,6 @@ impl Container for Wire { keyframe: false, // Legacy carries no per-frame duration. duration: None, - }]))) + }))) } } diff --git a/rs/moq-mux/src/container/loc/mod.rs b/rs/moq-mux/src/container/loc/mod.rs index ae819ee8d..73f183480 100644 --- a/rs/moq-mux/src/container/loc/mod.rs +++ b/rs/moq-mux/src/container/loc/mod.rs @@ -8,7 +8,7 @@ use std::task::Poll; use crate::container::Timestamp; -use crate::container::{Container, Frame}; +use crate::container::{Container, Frame, Read}; /// LOC's catalog convention: timestamps are in microseconds when no per-frame /// 0x08 timescale property is present. @@ -39,15 +39,11 @@ impl Container for Wire { Ok(()) } - fn poll_read( - &self, - group: &mut moq_net::GroupConsumer, - waiter: &kio::Waiter, - ) -> Poll>, Self::Error>> { + fn poll_read(&self, group: &mut moq_net::GroupConsumer, waiter: &kio::Waiter) -> Poll> { use std::task::ready; let Some(data) = ready!(group.poll_read_frame(waiter)?) else { - return Poll::Ready(Ok(None)); + return Poll::Ready(Ok(Read::Done)); }; let loc = moq_loc::decode(data)?; @@ -57,7 +53,7 @@ impl Container for Wire { let scale = loc.timescale.unwrap_or(DEFAULT_TIMESCALE); let timestamp = Timestamp::from_scale(loc.timestamp, scale).map_err(hang::Error::from)?; - Poll::Ready(Ok(Some(vec![Frame { + Poll::Ready(Ok(Read::Frame(Frame { timestamp, payload: loc.payload, // LOC doesn't carry the keyframe bit on the wire; the @@ -65,6 +61,6 @@ impl Container for Wire { keyframe: false, // LOC carries no per-frame duration. duration: None, - }]))) + }))) } } diff --git a/rs/moq-mux/src/container/mkv/export.rs b/rs/moq-mux/src/container/mkv/export.rs index 6383a4951..da28ee9cd 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -157,8 +157,7 @@ impl Export { /// /// `catalog` is any [`Stream`] of catalog snapshots, typically a /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or wrapped in - /// [`catalog::Filter`](crate::catalog::Filter) / - /// [`catalog::Target`](crate::catalog::Target) to narrow the rendition set. + /// [`catalog::Filter`](crate::catalog::Filter) to narrow the rendition set. pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { Self { broadcast, @@ -583,7 +582,7 @@ fn build_audio_track_entry(track_number: u64, config: &AudioConfig) -> Result), + + /// Building the Opus codec-private OpusHead for an audio track failed. + #[error(transparent)] + Opus(#[from] crate::codec::opus::Error), } impl From for Error { diff --git a/rs/moq-mux/src/container/mod.rs b/rs/moq-mux/src/container/mod.rs index 913bdeb27..ae05aeefd 100644 --- a/rs/moq-mux/src/container/mod.rs +++ b/rs/moq-mux/src/container/mod.rs @@ -97,24 +97,56 @@ pub trait Container { /// Encode one or more frames into a single moq-lite frame appended to `group`. fn write(&self, group: &mut moq_net::GroupProducer, frames: &[Frame]) -> Result<(), Self::Error>; - /// Poll the next moq-lite frame from `group` and decode it into media - /// frames. Returns `Ok(None)` when the group has ended. A single call - /// may produce multiple media frames (e.g. all samples in a CMAF - /// fragment). - fn poll_read( - &self, - group: &mut moq_net::GroupConsumer, - waiter: &kio::Waiter, - ) -> Poll>, Self::Error>>; + /// Poll the next moq-lite frame from `group` and decode it. Returns + /// [`Read::Done`] when the group has ended, [`Read::Frame`] for the common + /// one-frame-per-moq-frame case (Legacy, LOC), or [`Read::Fragment`] when a + /// single moq frame decodes into several media frames (a CMAF moof+mdat). + fn poll_read(&self, group: &mut moq_net::GroupConsumer, waiter: &kio::Waiter) -> Poll>; /// Async wrapper around [`Self::poll_read`]. - fn read( - &self, - group: &mut moq_net::GroupConsumer, - ) -> impl std::future::Future>, Self::Error>> + fn read(&self, group: &mut moq_net::GroupConsumer) -> impl std::future::Future> where Self: Sync, { async { kio::wait(|waiter| self.poll_read(group, waiter)).await } } } + +/// The outcome of one [`Container::poll_read`]. +/// +/// Splitting the single-frame case ([`Frame`](Read::Frame)) from the multi-frame +/// case ([`Fragment`](Read::Fragment)) lets the common one-frame-per-moq-frame +/// containers (Legacy, LOC) decode without allocating a `Vec` per frame. +#[derive(Debug)] +#[non_exhaustive] +pub enum Read { + /// The group has ended; there are no more frames. + Done, + /// A single decoded media frame. + Frame(Frame), + /// One moq frame decoded into several media frames, e.g. every sample in a + /// CMAF moof+mdat fragment. + Fragment(Vec), +} + +impl Read { + /// The decoded frames as a slice, so callers can iterate without matching the + /// variant: empty for [`Done`](Read::Done), one element for [`Frame`](Read::Frame), + /// or the whole batch for [`Fragment`](Read::Fragment). + pub fn frames(&self) -> &[Frame] { + match self { + Read::Done => &[], + Read::Frame(frame) => std::slice::from_ref(frame), + Read::Fragment(frames) => frames, + } + } +} + +impl<'a> IntoIterator for &'a Read { + type Item = &'a Frame; + type IntoIter = std::slice::Iter<'a, Frame>; + + fn into_iter(self) -> Self::IntoIter { + self.frames().iter() + } +} diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index c0ac31c3c..9e90d5b47 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -345,7 +345,7 @@ mod tests { &self, _group: &mut moq_net::GroupConsumer, _waiter: &kio::Waiter, - ) -> std::task::Poll>, Self::Error>> { + ) -> std::task::Poll> { unreachable!("Recording is write-only") } } diff --git a/rs/moq-mux/src/container/ts/catalog.rs b/rs/moq-mux/src/container/ts/catalog.rs index 6e2a1014d..6a5a6e43c 100644 --- a/rs/moq-mux/src/container/ts/catalog.rs +++ b/rs/moq-mux/src/container/ts/catalog.rs @@ -144,38 +144,24 @@ pub struct Ext { impl CatalogExt for Ext {} -/// An extension that can carry an `mpegts` catalog section. +/// Typed `&mut` access to the `mpegts` section of a catalog whose extension is +/// [`Ext`], or `None` for any other extension. /// -/// Implement this for an application extension to compose MPEG-TS carriage with -/// additional sections. -pub trait Catalog: CatalogExt { - /// The section to record MPEG-TS details into, or `None` for an extension that - /// doesn't carry them. - /// - /// Keep this stable per catalog: an importer samples support once at - /// construction, so a result that flips between `Some` and `None` mid-stream - /// would disable verbatim carriage or fail. - fn mpegts_mut(&mut self) -> Option<&mut Mpegts>; +/// Only [`Ext`] carries the typed section; `()`, the untyped +/// [`Extra`](crate::catalog::hang::Extra), and other application extensions +/// return `None`, so the TS importer falls back to no verbatim carriage. This is +/// a private detail so the public importers stay generic over plain +/// [`CatalogExt`] rather than a TS-specific trait. +pub(crate) fn mpegts_mut(catalog: &mut crate::catalog::hang::Catalog) -> Option<&mut Mpegts> { + (&mut catalog.ext as &mut dyn std::any::Any) + .downcast_mut::() + .map(|ext| &mut ext.mpegts) } -impl Catalog for () { - fn mpegts_mut(&mut self) -> Option<&mut Mpegts> { - None - } -} - -// The untyped passthrough carries no typed mpegts section (a TS importer driving an `Extra` -// catalog records verbatim streams as raw JSON sections, not the typed `Mpegts` view). -impl Catalog for crate::catalog::hang::Extra { - fn mpegts_mut(&mut self) -> Option<&mut Mpegts> { - None - } -} - -impl Catalog for Ext { - fn mpegts_mut(&mut self) -> Option<&mut Mpegts> { - Some(&mut self.mpegts) - } +/// Whether `E` carries a typed `mpegts` section (i.e. is [`Ext`]). The importer +/// samples this once at construction. +pub(crate) fn supports_mpegts() -> bool { + std::any::TypeId::of::() == std::any::TypeId::of::() } #[cfg(test)] diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index 92832f876..ae4cb724a 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -29,7 +29,7 @@ use mpeg2ts::ts::{ TsHeader, TsPacket, TsPacketWriter, TsPayload, VersionNumber, WriteTsPacket, }; -use crate::catalog::hang::Catalog; +use crate::catalog::hang::{Catalog, CatalogExt}; use crate::catalog::{CatalogFormat, Stream}; use crate::codec::annexb; use crate::container::{ExportSource, Frame, Timestamp}; @@ -51,7 +51,7 @@ const PSI_INTERVAL: Duration = Duration::from_millis(500); /// The leading PAT/PMT rides on the first frame (so it inherits a real /// timestamp), and is re-emitted at video keyframes and periodically for /// mid-stream tune-in. Returns `None` when the broadcast ends. -pub struct Export { +pub struct Export { broadcast: moq_net::BroadcastConsumer, catalog: Option>, latency: Duration, @@ -173,7 +173,7 @@ impl Export { } } -impl Export { +impl Export { /// Shared constructor. The public entry points each live on a concrete /// `Export` impl that pins `E`, so the extension is chosen by which one you call. fn build(broadcast: moq_net::BroadcastConsumer, catalog_format: CatalogFormat) -> Result { @@ -319,10 +319,10 @@ impl Export { } fn update_catalog(&mut self, mut catalog: Catalog) -> anyhow::Result<()> { - // The MPEG-TS section lives in the extension. The trait only exposes - // `mpegts_mut`, and this snapshot is owned, so clone it out (`()` yields the + // The MPEG-TS section lives in the extension, reachable only when `E` is the typed + // `Ext`. This snapshot is owned, so clone it out (any other extension yields the // empty default: no verbatim streams, no preserved PIDs/descriptors). - let mpegts = catalog.mpegts_mut().cloned().unwrap_or_default(); + let mpegts = catalog::mpegts_mut(&mut catalog).cloned().unwrap_or_default(); self.program_descriptors = mpegts.program_descriptors.clone(); // The desired track set: media renditions plus the verbatim streams. diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 689b12299..39d37fa14 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -64,7 +64,7 @@ async fn drain(consumer: moq_net::BroadcastConsumer) -> BytesMut { } /// `drain` for an exporter built with an explicit catalog extension. -async fn drain_with(mut exporter: Export) -> BytesMut { +async fn drain_with(mut exporter: Export) -> BytesMut { let mut out = BytesMut::new(); // `while let Ok` stops on the first timeout (`Pending`: no more output). while let Ok(res) = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()).await { diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index cb19727a3..694fff13f 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -39,7 +39,7 @@ use crate::container::Timestamp; /// by a program-level 'CUEI' registration descriptor, and other private sections) /// are intercepted before the reader and reassembled. With a base `Catalog<()>` /// they're logged and dropped instead. -pub struct Import { +pub struct Import { broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer, @@ -98,13 +98,12 @@ pub struct Import { media_unwrap: PtsUnwrap, } -impl Import { +impl Import { pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { let feed = Feed::default(); - // Sample the real catalog once at construction, not E::default(): an extension - // may carry the section by value, and a snapshot clones under the mutex (no publish). - let mut snapshot = catalog.snapshot(); - let supports_mpegts = snapshot.mpegts_mut().is_some(); + // Whether `E` carries a typed `mpegts` section. It's a property of the type, so + // sample it once: it can't change mid-stream. + let supports_mpegts = catalog::supports_mpegts::(); Self { broadcast, catalog, @@ -235,7 +234,8 @@ impl Import { // export re-emits them verbatim, including the original CUEI. if self.supports_mpegts && !self.program_recorded && !pmt.program_info.is_empty() { let program = to_descriptors(&pmt.program_info); - if let Some(mpegts) = self.catalog.lock().mpegts_mut() { + let mut guard = self.catalog.lock(); + if let Some(mpegts) = catalog::mpegts_mut(&mut guard) { mpegts.program_descriptors = program; } self.program_recorded = true; @@ -524,7 +524,8 @@ impl Import { self.es_descriptors.get(&pid.as_u16()).cloned().unwrap_or_default(), ) }; - if let Some(mpegts) = self.catalog.lock().mpegts_mut() { + let mut guard = self.catalog.lock(); + if let Some(mpegts) = catalog::mpegts_mut(&mut guard) { let entry = mpegts .tracks .entry(name) @@ -590,7 +591,7 @@ fn to_descriptors(descriptors: &[mpeg2ts::ts::Descriptor]) -> Vec( +fn register_verbatim( broadcast: &mut moq_net::BroadcastProducer, catalog: &mut crate::catalog::Producer, pid: u16, @@ -604,10 +605,10 @@ fn register_verbatim( let track = broadcast.unique_track(".ts")?; let mut guard = catalog.lock(); - let Some(mpegts) = guard.mpegts_mut() else { + let Some(mpegts) = catalog::mpegts_mut(&mut guard) else { // supports_mpegts was true when sampled at construction; None here means the - // catalog dropped the section since. - anyhow::bail!("catalog extension no longer carries an mpegts section"); + // extension type doesn't carry the section, which can't happen once sampled. + anyhow::bail!("catalog extension does not carry an mpegts section"); }; mpegts.tracks.insert( track.name().to_string(), @@ -626,8 +627,9 @@ fn register_verbatim( } /// Remove a verbatim track's entry from the `mpegts` catalog section on drop. -fn unregister_verbatim(catalog: &mut crate::catalog::Producer, name: &str) { - if let Some(mpegts) = catalog.lock().mpegts_mut() { +fn unregister_verbatim(catalog: &mut crate::catalog::Producer, name: &str) { + let mut guard = catalog.lock(); + if let Some(mpegts) = catalog::mpegts_mut(&mut guard) { mpegts.tracks.remove(name); } } @@ -639,13 +641,13 @@ fn unregister_verbatim(catalog: &mut crate::catalog::Produc /// intercepted before the mpeg2ts reader (which would PES-parse it and abort). /// The byte-level reassembly lives in [`SectionReassembler`]; this type owns the /// track and catalog entry and stamps each section with the media clock. -struct SectionStream { +struct SectionStream { track: crate::container::Producer, catalog: crate::catalog::Producer, reassembler: SectionReassembler, } -impl SectionStream { +impl SectionStream { fn new( mut broadcast: moq_net::BroadcastProducer, mut catalog: crate::catalog::Producer, @@ -704,7 +706,7 @@ impl SectionStream { } } -impl Drop for SectionStream { +impl Drop for SectionStream { fn drop(&mut self) { let name = self.track.name().to_string(); unregister_verbatim(&mut self.catalog, &name); @@ -717,7 +719,7 @@ impl Drop for SectionStream { /// /// Unlike [`SectionStream`], these ride the normal PES reassembly path, so this /// type only stamps each PES payload with its (unwrapped) PTS and writes it. -struct VerbatimStream { +struct VerbatimStream { track: crate::container::Producer, catalog: crate::catalog::Producer, unwrap: PtsUnwrap, @@ -725,7 +727,7 @@ struct VerbatimStream { stream_id_recorded: bool, } -impl VerbatimStream { +impl VerbatimStream { fn new( mut broadcast: moq_net::BroadcastProducer, mut catalog: crate::catalog::Producer, @@ -756,11 +758,13 @@ impl VerbatimStream { // re-emits the stream under its real id (e.g. 0xBD for teletext/DVB AC-3). if !self.stream_id_recorded { let name = self.track.name().to_string(); - if let Some(mpegts) = self.catalog.lock().mpegts_mut() + let mut guard = self.catalog.lock(); + if let Some(mpegts) = catalog::mpegts_mut(&mut guard) && let Some(verbatim) = mpegts.tracks.get_mut(&name).and_then(|t| t.verbatim.as_mut()) { verbatim.stream_id = Some(pending.stream_id); } + drop(guard); self.stream_id_recorded = true; } @@ -787,7 +791,7 @@ impl VerbatimStream { } } -impl Drop for VerbatimStream { +impl Drop for VerbatimStream { fn drop(&mut self) { let name = self.track.name().to_string(); unregister_verbatim(&mut self.catalog, &name); @@ -946,7 +950,7 @@ impl SectionReassembler { } /// One elementary stream's codec importer plus PTS-unwrap state. -enum Stream { +enum Stream { H264 { split: h264::Split, import: Box>, @@ -967,7 +971,7 @@ enum Stream { Ignored, } -impl Stream { +impl Stream { fn write(&mut self, pending: Pending, burst: Option) -> anyhow::Result<()> { match self { Stream::H264 { split, import, unwrap } => { diff --git a/rs/moq-mux/src/import/container.rs b/rs/moq-mux/src/import/container.rs index b40892d9f..a2a8904d2 100644 --- a/rs/moq-mux/src/import/container.rs +++ b/rs/moq-mux/src/import/container.rs @@ -10,7 +10,7 @@ use crate::Result; /// The concrete container importers, shared by [`Container`] and /// [`ContainerStream`]. Containers parse their own internal framing, so a whole /// chunk and a stream chunk decode identically. -enum ContainerImpl { +enum ContainerImpl { // Boxed because it's a large struct and clippy complains about the size. Fmp4(Box>), Mkv(Box>), @@ -18,7 +18,7 @@ enum ContainerImpl { Flv(Box>), } -impl ContainerImpl { +impl ContainerImpl { fn fmp4(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { ContainerImpl::Fmp4(Box::new(crate::container::fmp4::Import::new(broadcast, catalog))) } @@ -67,11 +67,11 @@ impl ContainerImpl { /// /// Use this when the caller hands over discrete buffers (the typical case for /// files and reassembled network input). May publish more than one track. -pub struct Container { +pub struct Container { inner: ContainerImpl, } -impl Container { +impl Container { /// Create a new container importer, decoding the initial chunk. pub fn new( broadcast: moq_net::BroadcastProducer, @@ -110,11 +110,11 @@ impl Container { /// /// Use this when the caller pushes arbitrary byte chunks and the container /// recovers its own framing. May publish more than one track. -pub struct ContainerStream { +pub struct ContainerStream { inner: ContainerImpl, } -impl ContainerStream { +impl ContainerStream { /// Create a new container stream importer. pub fn new( broadcast: moq_net::BroadcastProducer, diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index a770c59ab..ed52bbb21 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -5,8 +5,207 @@ //! own exactly one track, so they expose [`Track::demand`] / [`Track::name`] //! directly rather than fallibly. +use std::marker::PhantomData; + use crate::Result; use crate::catalog::hang::CatalogExt; +use crate::codec::{av1, h264, h265}; +use crate::container::{Frame, Timestamp}; + +/// Object-safe dispatch for a [`Track`] importer (whole frames). +trait Importer: Send { + fn decode(&mut self, frame: &[u8], pts: Option) -> Result<()>; + fn finish(&mut self) -> Result<()>; + fn seek(&mut self, sequence: u64) -> Result<()>; + fn demand(&self) -> moq_net::TrackDemand; +} + +/// Object-safe dispatch for a [`TrackStream`] importer (raw byte stream). +trait StreamImporter: Send { + fn initialize(&mut self, data: &[u8]) -> Result<()>; + fn decode(&mut self, data: &[u8]) -> Result<()>; + fn finish(&mut self) -> Result<()>; + fn seek(&mut self, sequence: u64) -> Result<()>; + fn demand(&self) -> moq_net::TrackDemand; +} + +/// The Annex-B / OBU splitter shared by H.264, H.265, and AV1. +trait Splitter: Send { + fn decode(&mut self, data: &[u8], pts: Option) -> Result>; + fn flush(&mut self, pts: Option) -> Result>; + fn reset(&mut self); +} + +/// A codec importer fed pre-split access units (H.264, H.265, AV1). +trait FrameSink: Send { + fn initialize(&mut self, init: &[u8]) -> Result<()>; + fn decode(&mut self, frames: Vec) -> Result<()>; + fn finish(&mut self) -> Result<()>; + fn seek(&mut self, sequence: u64) -> Result<()>; + fn demand(&self) -> moq_net::TrackDemand; +} + +macro_rules! impl_splitter { + ($ty:ty) => { + impl Splitter for $ty { + fn decode(&mut self, data: &[u8], pts: Option) -> Result> { + <$ty>::decode(self, data, pts) + } + fn flush(&mut self, pts: Option) -> Result> { + <$ty>::flush(self, pts) + } + fn reset(&mut self) { + <$ty>::reset(self) + } + } + }; +} +impl_splitter!(h264::Split); +impl_splitter!(h265::Split); +impl_splitter!(av1::Split); + +macro_rules! impl_frame_sink { + ($ty:ty) => { + impl FrameSink for $ty { + fn initialize(&mut self, init: &[u8]) -> Result<()> { + <$ty>::initialize(self, init) + } + fn decode(&mut self, frames: Vec) -> Result<()> { + <$ty>::decode(self, frames) + } + fn finish(&mut self) -> Result<()> { + <$ty>::finish(self) + } + fn seek(&mut self, sequence: u64) -> Result<()> { + <$ty>::seek(self, sequence) + } + fn demand(&self) -> moq_net::TrackDemand { + <$ty>::demand(self) + } + } + }; +} +impl_frame_sink!(h264::Import); +impl_frame_sink!(h265::Import); +impl_frame_sink!(av1::Import); + +/// Whole-frame split importer: each call is one access unit, so flush to emit it +/// rather than waiting for the next start code. +struct SplitWhole { + split: S, + import: I, +} + +impl Importer for SplitWhole { + fn decode(&mut self, frame: &[u8], pts: Option) -> Result<()> { + let mut frames = self.split.decode(frame, pts)?; + frames.extend(self.split.flush(pts)?); + self.import.decode(frames) + } + fn finish(&mut self) -> Result<()> { + self.import.finish() + } + fn seek(&mut self, sequence: u64) -> Result<()> { + self.split.reset(); + self.import.seek(sequence) + } + fn demand(&self) -> moq_net::TrackDemand { + self.import.demand() + } +} + +/// Byte-stream split importer: infer frame boundaries from a raw stream, flushing +/// only on [`finish`](StreamImporter::finish). +struct SplitStream { + split: S, + import: I, + /// True for AV1: the leading bytes are an out-of-band av1C config record, read + /// for config and dropped from the splitter rather than parsed as an OBU stream. + skip_config_record: bool, +} + +impl StreamImporter for SplitStream { + fn initialize(&mut self, data: &[u8]) -> Result<()> { + self.import.initialize(data)?; + let frames = if self.skip_config_record && is_av1c(data) { + Vec::new() + } else { + self.split.decode(data, None)? + }; + self.import.decode(frames) + } + fn decode(&mut self, data: &[u8]) -> Result<()> { + let frames = self.split.decode(data, None)?; + self.import.decode(frames) + } + fn finish(&mut self) -> Result<()> { + let tail = self.split.flush(None)?; + self.import.decode(tail)?; + self.import.finish() + } + fn seek(&mut self, sequence: u64) -> Result<()> { + self.split.reset(); + self.import.seek(sequence) + } + fn demand(&self) -> moq_net::TrackDemand { + self.import.demand() + } +} + +/// avc1 (length-prefixed NALU, out-of-band avcC). No splitter: each access unit is +/// wrapped directly via [`avc1_frame`](h264::avc1_frame). +struct Avc1 { + length_size: usize, + import: h264::Import, +} + +impl Importer for Avc1 { + fn decode(&mut self, frame: &[u8], pts: Option) -> Result<()> { + let pts = pts.ok_or(h264::Error::MissingTimestamp)?; + let frame = h264::avc1_frame(frame, self.length_size, pts)?; + self.import.decode([frame]) + } + fn finish(&mut self) -> Result<()> { + self.import.finish() + } + fn seek(&mut self, sequence: u64) -> Result<()> { + self.import.seek(sequence) + } + fn demand(&self) -> moq_net::TrackDemand { + self.import.demand() + } +} + +/// The codecs that carry their config in-band and need no splitter: each call wraps +/// one whole frame directly. +macro_rules! impl_importer_direct { + ($ty:ty) => { + impl Importer for $ty { + fn decode(&mut self, frame: &[u8], pts: Option) -> Result<()> { + <$ty>::decode(self, frame, pts) + } + fn finish(&mut self) -> Result<()> { + <$ty>::finish(self) + } + fn seek(&mut self, sequence: u64) -> Result<()> { + <$ty>::seek(self, sequence) + } + fn demand(&self) -> moq_net::TrackDemand { + <$ty>::demand(self) + } + } + }; +} +impl_importer_direct!(crate::codec::vp8::Import); +impl_importer_direct!(crate::codec::vp9::Import); +impl_importer_direct!(crate::codec::aac::Import); +impl_importer_direct!(crate::codec::opus::Import); + +/// An av1C config record (ISO/IEC 14496-15) starts with a 0x81 marker and is at +/// least 16 bytes; raw OBUs never look like this. +fn is_av1c(data: &[u8]) -> bool { + data.len() >= 16 && data[0] == 0x81 +} /// Build an H.264 avc3 split + import pair, resolving the config from `init`. /// @@ -63,10 +262,10 @@ fn build_av1( let mut import = crate::codec::av1::Import::new(track, catalog); import.initialize(init)?; let mut split = crate::codec::av1::Split::new(); - // av1C (leading 0x81, ISO/IEC 14496-15) is an out-of-band config record, not an - // OBU stream, so it's read for config (above) and dropped here. Raw OBUs are the - // leading bytes of the stream and feed the splitter. - let frames = if init.len() >= 16 && init[0] == 0x81 { + // av1C (ISO/IEC 14496-15) is an out-of-band config record, not an OBU stream, so it's + // read for config (above) and dropped here. Raw OBUs are the leading bytes of the + // stream and feed the splitter. + let frames = if is_av1c(init) { Vec::new() } else { split.decode(init, None)? @@ -75,41 +274,14 @@ fn build_av1( Ok((split, import)) } -enum TrackKind { - /// H.264 avc3 (Annex-B, inline SPS/PPS). The split owns byte parsing; the - /// import publishes. - Avc3 { - split: crate::codec::h264::Split, - import: crate::codec::h264::Import, - }, - /// H.264 avc1 (length-prefixed NALU, out-of-band avcC). No splitter: each - /// access unit is wrapped directly. `length_size` is the NALU length prefix - /// width read from the avcC. - Avc1 { - length_size: usize, - import: crate::codec::h264::Import, - }, - Hev1 { - split: crate::codec::h265::Split, - import: crate::codec::h265::Import, - }, - Av01 { - split: crate::codec::av1::Split, - import: crate::codec::av1::Import, - }, - Vp8(crate::codec::vp8::Import), - Vp9(crate::codec::vp9::Import), - Aac(crate::codec::aac::Import), - Opus(crate::codec::opus::Import), -} - /// A single-codec importer for whole frames. /// /// Use this when the caller already has whole frames (the typical case for files /// and reassembled network input). Each [`decode`](Self::decode) call takes one /// complete frame. pub struct Track { - kind: TrackKind, + inner: Box, + _ext: PhantomData, } impl Track { @@ -125,155 +297,70 @@ impl Track { format: &str, init: &[u8], ) -> Result { - let kind = match format { + let inner: Box = match format { "avc1" | "avcc" => { let (length_size, import) = build_h264_avc1(track, catalog, init)?; - TrackKind::Avc1 { length_size, import } + Box::new(Avc1 { length_size, import }) } "avc3" | "h264" => { let (split, import) = build_h264_avc3(track, catalog, init)?; - TrackKind::Avc3 { split, import } + Box::new(SplitWhole { split, import }) } "hev1" => { let (split, import) = build_h265(track, catalog, init)?; - TrackKind::Hev1 { split, import } + Box::new(SplitWhole { split, import }) } "av01" | "av1" | "av1c" | "av1C" => { let (split, import) = build_av1(track, catalog, init)?; - TrackKind::Av01 { split, import } + Box::new(SplitWhole { split, import }) } "vp8" | "vp08" => { let mut import = crate::codec::vp8::Import::new(track, catalog); import.initialize(init)?; - TrackKind::Vp8(import) + Box::new(import) } "vp9" | "vp09" => { let mut import = crate::codec::vp9::Import::new(track, catalog); import.initialize(init)?; - TrackKind::Vp9(import) + Box::new(import) } "aac" => { let mut data = init; let config = crate::codec::aac::Config::parse(&mut data)?; - let import = crate::codec::aac::Import::new(track, catalog, config)?; - TrackKind::Aac(import) + Box::new(crate::codec::aac::Import::new(track, catalog, config)?) } "opus" => { let mut data = init; let config = crate::codec::opus::Config::parse(&mut data)?; - let import = crate::codec::opus::Import::new(track, catalog, config)?; - TrackKind::Opus(import) + Box::new(crate::codec::opus::Import::new(track, catalog, config)?) } _ => return Err(crate::Error::UnknownFormat(format.to_string())), }; - Ok(Self { kind }) + Ok(Self { + inner, + _ext: PhantomData, + }) } /// Decode one whole frame. - pub fn decode(&mut self, frame: &[u8], pts: Option) -> Result<()> { - match self.kind { - TrackKind::Avc3 { - ref mut split, - ref mut import, - } => { - // One whole access unit per call, so flush to emit it rather than - // waiting for the next start code. - let mut frames = split.decode(frame, pts)?; - frames.extend(split.flush(pts)?); - import.decode(frames)?; - } - TrackKind::Avc1 { - length_size, - ref mut import, - } => { - let pts = pts.ok_or(crate::codec::h264::Error::MissingTimestamp)?; - let frame = crate::codec::h264::avc1_frame(frame, length_size, pts)?; - import.decode([frame])?; - } - TrackKind::Hev1 { - ref mut split, - ref mut import, - } => { - let mut frames = split.decode(frame, pts)?; - frames.extend(split.flush(pts)?); - import.decode(frames)?; - } - TrackKind::Av01 { - ref mut split, - ref mut import, - } => { - let mut frames = split.decode(frame, pts)?; - frames.extend(split.flush(pts)?); - import.decode(frames)?; - } - TrackKind::Vp8(ref mut import) => import.decode(frame, pts)?, - TrackKind::Vp9(ref mut import) => import.decode(frame, pts)?, - TrackKind::Aac(ref mut import) => import.decode(frame, pts)?, - TrackKind::Opus(ref mut import) => import.decode(frame, pts)?, - } - - Ok(()) + pub fn decode(&mut self, frame: &[u8], pts: Option) -> Result<()> { + self.inner.decode(frame, pts) } /// Finish the importer, flushing any buffered data. pub fn finish(&mut self) -> Result<()> { - match self.kind { - TrackKind::Avc3 { ref mut import, .. } => import.finish(), - TrackKind::Avc1 { ref mut import, .. } => import.finish(), - TrackKind::Hev1 { ref mut import, .. } => import.finish(), - TrackKind::Av01 { ref mut import, .. } => import.finish(), - TrackKind::Vp8(ref mut import) => import.finish(), - TrackKind::Vp9(ref mut import) => import.finish(), - TrackKind::Aac(ref mut import) => import.finish(), - TrackKind::Opus(ref mut import) => import.finish(), - } + self.inner.finish() } /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> Result<()> { - match self.kind { - TrackKind::Avc3 { - ref mut split, - ref mut import, - } => { - split.reset(); - import.seek(sequence) - } - TrackKind::Avc1 { ref mut import, .. } => import.seek(sequence), - TrackKind::Hev1 { - ref mut split, - ref mut import, - } => { - split.reset(); - import.seek(sequence) - } - TrackKind::Av01 { - ref mut split, - ref mut import, - } => { - split.reset(); - import.seek(sequence) - } - TrackKind::Vp8(ref mut import) => import.seek(sequence), - TrackKind::Vp9(ref mut import) => import.seek(sequence), - TrackKind::Aac(ref mut import) => import.seek(sequence), - TrackKind::Opus(ref mut import) => import.seek(sequence), - } + self.inner.seek(sequence) } /// A watch-only handle to the track's subscriber demand. pub fn demand(&self) -> moq_net::TrackDemand { - match self.kind { - TrackKind::Avc3 { ref import, .. } => import.demand(), - TrackKind::Avc1 { ref import, .. } => import.demand(), - TrackKind::Hev1 { ref import, .. } => import.demand(), - TrackKind::Av01 { ref import, .. } => import.demand(), - TrackKind::Vp8(ref import) => import.demand(), - TrackKind::Vp9(ref import) => import.demand(), - TrackKind::Aac(ref import) => import.demand(), - TrackKind::Opus(ref import) => import.demand(), - } + self.inner.demand() } /// The name of the track this importer publishes. @@ -288,7 +375,8 @@ impl Track { impl From> for Track { fn from(opus: crate::codec::opus::Import) -> Self { Self { - kind: TrackKind::Opus(opus), + inner: Box::new(opus), + _ext: PhantomData, } } } @@ -296,34 +384,19 @@ impl From> for Track { impl From> for Track { fn from(aac: crate::codec::aac::Import) -> Self { Self { - kind: TrackKind::Aac(aac), + inner: Box::new(aac), + _ext: PhantomData, } } } -enum TrackStreamKind { - /// H.264 in avc3 wire shape (Annex-B with inline SPS/PPS). The split owns - /// byte parsing; the import publishes. - Avc3 { - split: crate::codec::h264::Split, - import: crate::codec::h264::Import, - }, - Hev1 { - split: crate::codec::h265::Split, - import: crate::codec::h265::Import, - }, - Av01 { - split: crate::codec::av1::Split, - import: crate::codec::av1::Import, - }, -} - /// A single-codec importer for a raw byte stream with unknown frame boundaries. /// /// Use this when the caller does not know the frame boundaries (piped Annex-B /// H.264, an fMP4 reader, …); the importer infers them. pub struct TrackStream { - kind: TrackStreamKind, + inner: Box, + _ext: PhantomData, } impl TrackStream { @@ -335,156 +408,56 @@ impl TrackStream { /// the legacy microsecond timescale. pub fn new(track: moq_net::TrackProducer, catalog: crate::catalog::Producer, format: &str) -> Result { // Only the self-delimiting codecs can be recovered from a raw byte stream. - let kind = match format { - "avc3" | "h264" => TrackStreamKind::Avc3 { - split: crate::codec::h264::Split::new(), - import: crate::codec::h264::Import::new(track, catalog), - }, - "hev1" => TrackStreamKind::Hev1 { - split: crate::codec::h265::Split::new(), - import: crate::codec::h265::Import::new(track, catalog), - }, - "av01" | "av1" | "av1c" | "av1C" => TrackStreamKind::Av01 { - split: crate::codec::av1::Split::new(), - import: crate::codec::av1::Import::new(track, catalog), - }, + let inner: Box = match format { + "avc3" | "h264" => Box::new(SplitStream { + split: h264::Split::new(), + import: h264::Import::new(track, catalog), + skip_config_record: false, + }), + "hev1" => Box::new(SplitStream { + split: h265::Split::new(), + import: h265::Import::new(track, catalog), + skip_config_record: false, + }), + "av01" | "av1" | "av1c" | "av1C" => Box::new(SplitStream { + split: av1::Split::new(), + import: av1::Import::new(track, catalog), + skip_config_record: true, + }), _ => return Err(crate::Error::UnknownFormat(format.to_string())), }; - Ok(Self { kind }) + Ok(Self { + inner, + _ext: PhantomData, + }) } /// Initialize the importer with the given buffer and populate the broadcast. /// /// This is not required for self-describing formats like AVC3. pub fn initialize(&mut self, data: &[u8]) -> Result<()> { - match self.kind { - TrackStreamKind::Avc3 { - ref mut split, - ref mut import, - } => { - import.initialize(data)?; - let frames = split.decode(data, None)?; - import.decode(frames)?; - } - TrackStreamKind::Hev1 { - ref mut split, - ref mut import, - } => { - import.initialize(data)?; - let frames = split.decode(data, None)?; - import.decode(frames)?; - } - TrackStreamKind::Av01 { - ref mut split, - ref mut import, - } => { - import.initialize(data)?; - // av1C (leading 0x81) is an out-of-band config record, not an OBU - // stream; read for config above and dropped here. - let frames = if data.len() >= 16 && data[0] == 0x81 { - Vec::new() - } else { - split.decode(data, None)? - }; - import.decode(frames)?; - } - } - - Ok(()) + self.inner.initialize(data) } /// Decode a chunk of the byte stream. pub fn decode(&mut self, data: &[u8]) -> Result<()> { - match self.kind { - TrackStreamKind::Avc3 { - ref mut split, - ref mut import, - } => { - let frames = split.decode(data, None)?; - import.decode(frames) - } - TrackStreamKind::Hev1 { - ref mut split, - ref mut import, - } => { - let frames = split.decode(data, None)?; - import.decode(frames) - } - TrackStreamKind::Av01 { - ref mut split, - ref mut import, - } => { - let frames = split.decode(data, None)?; - import.decode(frames) - } - } + self.inner.decode(data) } /// Finish the importer, flushing any buffered data. pub fn finish(&mut self) -> Result<()> { - match self.kind { - TrackStreamKind::Avc3 { - ref mut split, - ref mut import, - } => { - let tail = split.flush(None)?; - import.decode(tail)?; - import.finish() - } - TrackStreamKind::Hev1 { - ref mut split, - ref mut import, - } => { - let tail = split.flush(None)?; - import.decode(tail)?; - import.finish() - } - TrackStreamKind::Av01 { - ref mut split, - ref mut import, - } => { - let tail = split.flush(None)?; - import.decode(tail)?; - import.finish() - } - } + self.inner.finish() } /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> Result<()> { - match self.kind { - TrackStreamKind::Avc3 { - ref mut split, - ref mut import, - } => { - split.reset(); - import.seek(sequence) - } - TrackStreamKind::Hev1 { - ref mut split, - ref mut import, - } => { - split.reset(); - import.seek(sequence) - } - TrackStreamKind::Av01 { - ref mut split, - ref mut import, - } => { - split.reset(); - import.seek(sequence) - } - } + self.inner.seek(sequence) } /// A watch-only handle to the track's subscriber demand. pub fn demand(&self) -> moq_net::TrackDemand { - match self.kind { - TrackStreamKind::Avc3 { ref import, .. } => import.demand(), - TrackStreamKind::Hev1 { ref import, .. } => import.demand(), - TrackStreamKind::Av01 { ref import, .. } => import.demand(), - } + self.inner.demand() } /// The name of the track this importer publishes. diff --git a/rs/moq-mux/src/lib.rs b/rs/moq-mux/src/lib.rs index 6d1242ab5..3e6e87518 100644 --- a/rs/moq-mux/src/lib.rs +++ b/rs/moq-mux/src/lib.rs @@ -24,3 +24,8 @@ pub mod import; pub use clock::Clock; pub use error::*; + +/// Re-export of the [`mp4_atom`] crate, whose types appear in the public CMAF +/// surface ([`container::fmp4`]). A major version bump of `mp4_atom` is a +/// breaking change for moq-mux. +pub use mp4_atom; From 1ea203da8acbede6533278f02c536e09be4add29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:58:19 -0700 Subject: [PATCH 28/34] build(deps): bump the cargo group across 1 directory with 18 updates (#1942) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Luke Curley Co-authored-by: Claude Opus 4.8 --- Cargo.lock | 199 +++++++++++++++++++++++--------------- rs/moq-cli/Cargo.toml | 2 +- rs/moq-relay/Cargo.toml | 2 +- rs/moq-rtc/Cargo.toml | 4 +- rs/moq-rtc/src/session.rs | 2 +- rs/moq-rtmp/Cargo.toml | 2 +- rs/moq-srt/Cargo.toml | 2 +- 7 files changed, 126 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 353307533..fc15bdcdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -752,9 +752,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" dependencies = [ "serde", ] @@ -820,9 +820,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cbindgen" -version = "0.29.2" +version = "0.29.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" dependencies = [ "clap", "heck", @@ -939,9 +939,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1657,7 +1657,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -3124,7 +3123,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -3362,7 +3361,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.3", + "socket2 0.6.4", "widestring", "windows-registry", "windows-result", @@ -3953,9 +3952,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memoffset" @@ -4107,7 +4106,7 @@ dependencies = [ "rustls", "sd-notify", "tokio", - "tower-http", + "tower-http 0.7.0", "tracing", "url", ] @@ -4265,7 +4264,7 @@ dependencies = [ "rustls-webpki", "serde", "serde_with", - "socket2 0.6.3", + "socket2 0.6.4", "tempfile", "thiserror 2.0.18", "tikv-jemalloc-ctl", @@ -4335,7 +4334,7 @@ dependencies = [ "tokio", "tokio-rustls", "toml 1.1.2+spec-1.1.0", - "tower-http", + "tower-http 0.7.0", "tower-service", "tracing", "url", @@ -4362,7 +4361,7 @@ dependencies = [ "str0m", "thiserror 2.0.18", "tokio", - "tower-http", + "tower-http 0.7.0", "tracing", "url", "uuid", @@ -4384,11 +4383,11 @@ dependencies = [ "rml_rtmp", "rustls", "sd-notify", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tokio-rustls", - "tower-http", + "tower-http 0.7.0", "tracing", "url", ] @@ -4412,7 +4411,7 @@ dependencies = [ "srt-tokio", "thiserror 2.0.18", "tokio", - "tower-http", + "tower-http 0.7.0", "tracing", "url", ] @@ -4712,7 +4711,7 @@ dependencies = [ "objc2-system-configuration", "pin-project-lite", "serde", - "socket2 0.6.3", + "socket2 0.6.4", "time", "tokio", "tokio-util", @@ -4775,7 +4774,7 @@ dependencies = [ "pin-project-lite", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -4820,7 +4819,7 @@ checksum = "cd5a37756f168cf350d68a97c4f0158bdf3c76f10175123941569b09ab51f011" dependencies = [ "cfg_aliases", "libc", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.61.2", ] @@ -5575,6 +5574,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkcs1" version = "0.7.5" @@ -5706,7 +5711,7 @@ dependencies = [ "rand 0.10.1", "serde", "smallvec", - "socket2 0.6.3", + "socket2 0.6.4", "time", "tokio", "tokio-util", @@ -6030,9 +6035,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -6041,7 +6046,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -6081,7 +6086,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -6276,9 +6281,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -6299,9 +6304,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -6332,7 +6337,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -6369,7 +6374,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -6540,9 +6545,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -6556,9 +6561,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -6961,9 +6966,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64", "bs58", @@ -6981,9 +6986,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -7257,9 +7262,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smawk" @@ -7279,9 +7284,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -7406,9 +7411,9 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "str0m" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "431befc786d98bfa860118d96890df038e4db51635494203bbe600b05582f920" +checksum = "ca05746700d3621a27d7b99beaf2e724f8940608cbab00c3e4ebd974620668af" dependencies = [ "arrayvec", "base64ct", @@ -7727,12 +7732,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "js-sys", "libc", "num-conv", @@ -7745,15 +7749,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -7806,7 +7810,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -8113,6 +8117,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ "bitflags", "bytes", "futures-core", @@ -8128,10 +8154,8 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower", "tower-layer", "tower-service", - "url", ] [[package]] @@ -8327,9 +8351,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uniffi" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" +checksum = "46eefd5468602930da46b1f49d3448c6dfc2e81295f93120f23f8174fd70267f" dependencies = [ "anyhow", "camino", @@ -8344,9 +8368,9 @@ dependencies = [ [[package]] name = "uniffi_bindgen" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" +checksum = "c4a0c9b375d32e1365cdb2bdd7cb495eecf6fac851ddbad077412b4ee1888514" dependencies = [ "anyhow", "askama", @@ -8370,9 +8394,9 @@ dependencies = [ [[package]] name = "uniffi_build" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c39413c43b955e4aa8a4e2b34bbd1b6b5ff6bd85532b52f9eb92fbe88c14458" +checksum = "744fe15bcd3e2b1712a4573a45ce749af19cf28d69027ca5789619014955668c" dependencies = [ "anyhow", "camino", @@ -8381,9 +8405,9 @@ dependencies = [ [[package]] name = "uniffi_core" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" +checksum = "eec017b112701681f6fbbe5d92014b5c468eb0b177a94389de03ceec40665095" dependencies = [ "anyhow", "bytes", @@ -8393,9 +8417,9 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" +checksum = "4641669b48fefbc5e80ff08c5004d9c7617fb91232131a6734ab6712779cb04c" dependencies = [ "anyhow", "indexmap 2.14.0", @@ -8406,9 +8430,9 @@ dependencies = [ [[package]] name = "uniffi_macros" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" +checksum = "eeb8617ee814de22caf7417bf514715ba0b3f46bd9d5a5d794413fd8282cb737" dependencies = [ "camino", "fs-err 2.11.0", @@ -8423,9 +8447,9 @@ dependencies = [ [[package]] name = "uniffi_meta" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" +checksum = "58d5b94fc92803d21b2928bd15c6f06e57609b95caf98ea561c99cda1b6d2a25" dependencies = [ "anyhow", "siphasher", @@ -8435,9 +8459,9 @@ dependencies = [ [[package]] name = "uniffi_pipeline" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" +checksum = "032739b3ec725576914c15899dedaf080163ced86b6934566c20ec2b20ce90ca" dependencies = [ "anyhow", "heck", @@ -8448,9 +8472,9 @@ dependencies = [ [[package]] name = "uniffi_udl" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" +checksum = "fc0a1d0a0252ce1af9e8ce78ba67ac0d8937fb2bedaf10cbddd43d3614d06ec6" dependencies = [ "anyhow", "textwrap", @@ -8518,9 +8542,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -8744,15 +8768,30 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + [[package]] name = "web-async" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5414b65d9a5094649bb99987bb74db71febfdfa3677b7954a0a05c99d0424e8" +checksum = "16f56ac33e792583916a8021e43e8a7e0987f5df7abc8f8afd72fcc361048755" dependencies = [ "tokio", "tracing", "wasm-bindgen-futures", + "wasmtimer", ] [[package]] @@ -8869,9 +8908,9 @@ dependencies = [ [[package]] name = "web-transport-trait" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc5f83d19cf6c8ba147f4e1e5935a8a115c91f6abbf714d740a83b967d558e6e" +checksum = "9959d7a7db997953305c49e0db5087fb495614a186d1cdfa98c40f9a194dbe5d" dependencies = [ "bytes", ] diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 1cbefb988..792bb8e8c 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -32,7 +32,7 @@ moq-mux = { workspace = true } moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"] } rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false } tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.6", features = ["cors", "fs"] } +tower-http = { version = "0.7", features = ["cors", "fs"] } tracing = "0.1" url = "2" diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 1fbc87c5f..abac404bf 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -56,7 +56,7 @@ thiserror = "2" tokio = { workspace = true, features = ["full"] } tokio-rustls = { version = "0.26", default-features = false } toml = "1.1" -tower-http = { version = "0.6", features = ["cors"] } +tower-http = { version = "0.7", features = ["cors"] } tower-service = "0.3" tracing = "0.1" url = { version = "2", features = ["serde"] } diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index d35fb619c..59c8c04a4 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -52,10 +52,10 @@ moq-native = { workspace = true, default-features = false, features = ["aws-lc-r moq-net = { workspace = true } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false, optional = true } -str0m = "0.19" +str0m = "0.20" thiserror = "2" tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.6", features = ["cors"], optional = true } +tower-http = { version = "0.7", features = ["cors"], optional = true } tracing = "0.1" url = "2" uuid = { version = "1", features = ["v4"] } diff --git a/rs/moq-rtc/src/session.rs b/rs/moq-rtc/src/session.rs index d8f774bab..a01562bc6 100644 --- a/rs/moq-rtc/src/session.rs +++ b/rs/moq-rtc/src/session.rs @@ -236,7 +236,7 @@ impl Session { data.mid, codec::Frame { timestamp_us, - payload: data.data.into(), + payload: bytes::Bytes::from_owner(data.data), }, )?; } diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index 108f8e1b4..dace71ae3 100644 --- a/rs/moq-rtmp/Cargo.toml +++ b/rs/moq-rtmp/Cargo.toml @@ -64,7 +64,7 @@ tokio = { workspace = true, features = ["full"] } # workspace shares one tokio-rustls; the crypto provider comes from the supplied # `rustls::ServerConfig`, so no default crypto feature is needed here. tokio-rustls = { version = "0.26", default-features = false, optional = true } -tower-http = { version = "0.6", features = ["cors", "fs"], optional = true } +tower-http = { version = "0.7", features = ["cors", "fs"], optional = true } tracing = "0.1" url = { version = "2", optional = true } diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index d442d6a16..f55d6367d 100644 --- a/rs/moq-srt/Cargo.toml +++ b/rs/moq-srt/Cargo.toml @@ -54,7 +54,7 @@ rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"], srt-tokio = "0.4" thiserror = "2" tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.6", features = ["cors", "fs"], optional = true } +tower-http = { version = "0.7", features = ["cors", "fs"], optional = true } tracing = "0.1" url = { version = "2", optional = true } From 68c9ebd483c25bc2373b89d4b499ac8fd6ca5828 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 29 Jun 2026 14:36:40 -0700 Subject: [PATCH 29/34] moq-mux: unify rendition selection behind select::Broadcast Replace the consume-side-only catalog::Filter with one selection type that reads the same at both ends of the pipeline. - New moq_mux::select module: Broadcast/Video/Audio fluent builders (default selects nothing; opt a role in, narrow by name/codec, empty field = any). - catalog::Select replaces Filter/FilterVideo/FilterAudio; Stream::filter() -> Stream::select(selection). The adapter is immutable, dropping the old reactive set-mid-stream machinery. - fmp4::Import::with_select restricts which roles are published (new() unchanged, so existing callers still import everything). - moq-hls uses it on both ends: per-rendition export selects only its own axis, and import publishes master-playlist variants video-only beside a separate audio rendition (the track-selection half of #1940). Breaking moq-mux change, targeting main during the pre-semver-bump window. The discontinuity-sequence change from #1940 is intentionally left for a separate PR. --- rs/moq-hls/src/export/mod.rs | 2 +- rs/moq-hls/src/export/rendition.rs | 25 +- rs/moq-hls/src/import.rs | 121 ++++++- rs/moq-mux/src/catalog/consumer.rs | 2 +- rs/moq-mux/src/catalog/filter.rs | 362 ------------------- rs/moq-mux/src/catalog/mod.rs | 11 +- rs/moq-mux/src/catalog/select.rs | 88 +++++ rs/moq-mux/src/catalog/stream.rs | 12 +- rs/moq-mux/src/codec/h264/export.rs | 11 +- rs/moq-mux/src/codec/h265/export.rs | 2 +- rs/moq-mux/src/container/fmp4/export.rs | 4 +- rs/moq-mux/src/container/fmp4/import.rs | 74 +++- rs/moq-mux/src/container/fmp4/import_test.rs | 52 +++ rs/moq-mux/src/container/mkv/export.rs | 2 +- rs/moq-mux/src/lib.rs | 3 + rs/moq-mux/src/select.rs | 249 +++++++++++++ 16 files changed, 588 insertions(+), 432 deletions(-) delete mode 100644 rs/moq-mux/src/catalog/filter.rs create mode 100644 rs/moq-mux/src/catalog/select.rs create mode 100644 rs/moq-mux/src/select.rs diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 1eae0c1c0..630e26731 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -2,7 +2,7 @@ //! //! A [`Broadcaster`] watches one broadcast's catalog and, per rendition, runs a //! [`moq_mux::container::fmp4::Export`] narrowed to that single track (via -//! [`moq_mux::catalog::Filter`]) feeding a [`store::SegmentStore`]. The HTTP +//! [`moq_mux::catalog::Select`]) feeding a [`store::SegmentStore`]. The HTTP //! [`server`](crate::server) reads the stores to answer playlist and segment //! requests. diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index d9e29ae3c..a992a3611 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -3,8 +3,9 @@ use std::sync::Arc; use hang::catalog::{AudioConfig, VideoConfig}; -use moq_mux::catalog::{self, CatalogFormat, Filter, FilterAudio, FilterVideo}; +use moq_mux::catalog::{self, CatalogFormat, Stream}; use moq_mux::container::fmp4::Export; +use moq_mux::select; use tokio::sync::watch; use super::Config; @@ -96,7 +97,7 @@ fn spawn_pump( paused: watch::Receiver, ) { tokio::spawn(async move { - if let Err(err) = run_pump(broadcast, &name, &store, &cfg, paused).await { + if let Err(err) = run_pump(broadcast, &name, kind, &store, &cfg, paused).await { tracing::warn!(%name, ?kind, %err, "hls rendition pump ended with error"); } // Whatever happened, mark the playlist closed so blocking readers wake. @@ -107,29 +108,25 @@ fn spawn_pump( async fn run_pump( broadcast: moq_net::BroadcastConsumer, name: &str, + kind: Kind, store: &SegmentStore, cfg: &Config, mut paused: watch::Receiver, ) -> Result<()> { let consumer = catalog::Consumer::<()>::new(&broadcast, CatalogFormat::Hang)?; - let mut filter = Filter::new(consumer); - // Narrow *both* axes to this rendition's name so the exporter sees exactly one - // track: the opposite axis can't hold a rendition with this name, so it empties. - filter.set_video(FilterVideo { - name: Some(name.to_string()), - ..Default::default() - }); - filter.set_audio(FilterAudio { - name: Some(name.to_string()), - ..Default::default() - }); + // Select this rendition's name on its own axis so the exporter sees exactly one track. + let selection = match kind { + Kind::Video => select::Broadcast::default().video(select::Video::default().name(name)), + Kind::Audio => select::Broadcast::default().audio(select::Audio::default().name(name)), + }; + let filtered = consumer.select(selection); // A handle for noticing the broadcast close even while paused; the `Export` // below takes its own clone for pulling fragments. let closed = broadcast.clone(); - let mut export = Export::new(broadcast, filter) + let mut export = Export::new(broadcast, filtered) .with_fragment_duration(cfg.part_target) .with_latency(cfg.latency); diff --git a/rs/moq-hls/src/import.rs b/rs/moq-hls/src/import.rs index a586697d0..a0249b7ac 100644 --- a/rs/moq-hls/src/import.rs +++ b/rs/moq-hls/src/import.rs @@ -16,6 +16,7 @@ use m3u8_rs::{ }; use moq_mux::catalog::Producer as CatalogProducer; use moq_mux::container::fmp4::Import as Fmp4; +use moq_mux::select; use reqwest::Client; use tracing::{debug, info, warn}; use url::Url; @@ -107,20 +108,41 @@ enum TrackKind { struct TrackState { playlist: Url, + // Which roles this playlist's importer publishes (a muxed variant alongside a + // separate audio rendition publishes video only). + select: select::Broadcast, next_sequence: Option, init_ready: bool, } impl TrackState { - fn new(playlist: Url) -> Self { + fn new(playlist: Url, select: select::Broadcast) -> Self { Self { playlist, + select, next_sequence: None, init_ready: false, } } } +/// Selection for a muxed rendition (the only source): publish every track. +fn select_muxed() -> select::Broadcast { + select::Broadcast::default() + .video(select::Video::default()) + .audio(select::Audio::default()) +} + +/// Selection for a video variant that has a separate audio rendition: video only. +fn select_video_only() -> select::Broadcast { + select::Broadcast::default().video(select::Video::default()) +} + +/// Selection for a separate audio rendition: audio only. +fn select_audio_only() -> select::Broadcast { + select::Broadcast::default().audio(select::Audio::default()) +} + impl Import { /// Create a new HLS import that will write into the given broadcast. pub fn new(broadcast: moq_net::BroadcastProducer, catalog: CatalogProducer, cfg: Config) -> Result { @@ -309,18 +331,13 @@ impl Import { return Err(Error::NoVariants); } - // Create a video track state for every usable variant. - for variant in &variants { - let video_url = resolve_uri(&self.base_url, &variant.uri)?; - self.video.push(TrackState::new(video_url)); - } - - // Choose an audio rendition based on the first variant with an audio group. + // Choose an audio rendition first, so the video variants below know whether + // they need to drop their muxed audio. if let Some(group_id) = variants.iter().find_map(|v| v.audio.as_deref()) { if let Some(audio_tag) = select_audio(&master, group_id) { if let Some(uri) = &audio_tag.uri { let audio_url = resolve_uri(&self.base_url, uri)?; - self.audio = Some(TrackState::new(audio_url)); + self.audio = Some(TrackState::new(audio_url, select_audio_only())); } else { warn!(%group_id, "audio rendition missing URI"); } @@ -329,6 +346,18 @@ impl Import { } } + // With a separate audio rendition, the variants are muxed but should publish + // video only so the audio isn't duplicated; otherwise import every track. + let variant_select = if self.audio.is_some() { + select_video_only() + } else { + select_muxed() + }; + for variant in &variants { + let video_url = resolve_uri(&self.base_url, &variant.uri)?; + self.video.push(TrackState::new(video_url, variant_select.clone())); + } + let audio_url = self.audio.as_ref().map(|a| a.playlist.to_string()); info!( video_variants = variants.len(), @@ -339,8 +368,8 @@ impl Import { return Ok(()); } - // Fallback: treat the provided URL as a single media playlist. - self.video.push(TrackState::new(self.base_url.clone())); + // Fallback: treat the provided URL as a single (muxed) media playlist. + self.video.push(TrackState::new(self.base_url.clone(), select_muxed())); Ok(()) } @@ -433,8 +462,8 @@ impl Import { let url = resolve_uri(&track.playlist, &map.uri)?; let bytes = self.fetch_bytes(url).await?; let importer = match kind { - TrackKind::Video(index) => self.ensure_video_importer_for(index), - TrackKind::Audio => self.ensure_audio_importer(), + TrackKind::Video(index) => self.ensure_video_importer_for(index, &track.select), + TrackKind::Audio => self.ensure_audio_importer(&track.select), }; // The importer buffers internally, so a fully-parsed init segment leaves it @@ -464,8 +493,8 @@ impl Import { // `consume_segments` always runs `ensure_init_segment` before reaching here, so // the importer is already initialized. let importer = match kind { - TrackKind::Video(index) => self.ensure_video_importer_for(index), - TrackKind::Audio => self.ensure_audio_importer(), + TrackKind::Video(index) => self.ensure_video_importer_for(index, &track.select), + TrackKind::Audio => self.ensure_audio_importer(&track.select), }; importer.decode(&bytes)?; @@ -495,9 +524,9 @@ impl Import { /// /// Each video variant gets its own importer so that their tracks remain /// independent while still contributing to the same shared catalog. - fn ensure_video_importer_for(&mut self, index: usize) -> &mut Fmp4 { + fn ensure_video_importer_for(&mut self, index: usize, select: &select::Broadcast) -> &mut Fmp4 { while self.video_importers.len() <= index { - let importer = Fmp4::new(self.broadcast.clone(), self.catalog.clone()); + let importer = Fmp4::new(self.broadcast.clone(), self.catalog.clone()).with_select(select.clone()); self.video_importers.push(importer); } @@ -505,9 +534,12 @@ impl Import { } /// Create or retrieve the fMP4 importer for the audio rendition. - fn ensure_audio_importer(&mut self) -> &mut Fmp4 { + fn ensure_audio_importer(&mut self, select: &select::Broadcast) -> &mut Fmp4 { + let broadcast = self.broadcast.clone(); + let catalog = self.catalog.clone(); + let select = select.clone(); self.audio_importer - .get_or_insert_with(|| Fmp4::new(self.broadcast.clone(), self.catalog.clone())) + .get_or_insert_with(|| Fmp4::new(broadcast, catalog).with_select(select)) } #[cfg(test)] @@ -659,4 +691,55 @@ mod tests { assert!(!hls.has_video_importer()); assert!(!hls.has_audio_importer()); } + + /// Resolve `ensure_tracks` against a master playlist written to a temp file. + async fn discover(master_body: &str) -> Import { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("moq-hls-test-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("master.m3u8"); + std::fs::write(&path, master_body).unwrap(); + + let mut broadcast = moq_net::Broadcast::new().produce(); + let catalog = CatalogProducer::new(&mut broadcast).unwrap(); + // `Config` takes a filesystem path for non-http inputs. + let cfg = Config::new(path.to_str().unwrap().to_string()); + let mut hls = Import::new(broadcast, catalog, cfg).unwrap(); + hls.ensure_tracks().await.unwrap(); + hls + } + + /// A master with a separate audio rendition: variants publish video only, the + /// alternate rendition publishes audio only. + #[tokio::test] + async fn discover_splits_separate_audio() { + let master = "#EXTM3U\n\ + #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",URI=\"audio.m3u8\"\n\ + #EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.4d401f,mp4a.40.2\",AUDIO=\"aud\"\n\ + video.m3u8\n"; + let hls = discover(master).await; + + assert_eq!(hls.video.len(), 1); + assert!(hls.video[0].select.has_video() && !hls.video[0].select.has_audio()); + + let audio = hls.audio.as_ref().expect("separate audio rendition"); + assert!(audio.select.has_audio() && !audio.select.has_video()); + } + + /// A master whose variant carries muxed A/V (no separate audio group) publishes + /// every track. + #[tokio::test] + async fn discover_muxed_variant_keeps_both() { + let master = "#EXTM3U\n\ + #EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.4d401f\"\n\ + video.m3u8\n"; + let hls = discover(master).await; + + assert_eq!(hls.video.len(), 1); + assert!(hls.video[0].select.has_video() && hls.video[0].select.has_audio()); + assert!(hls.audio.is_none()); + } } diff --git a/rs/moq-mux/src/catalog/consumer.rs b/rs/moq-mux/src/catalog/consumer.rs index d07d0aeaf..2637d39b3 100644 --- a/rs/moq-mux/src/catalog/consumer.rs +++ b/rs/moq-mux/src/catalog/consumer.rs @@ -13,7 +13,7 @@ use super::{CatalogFormat, Stream}; /// /// Both variants emit [`Catalog`](super::hang::Catalog); the MSF variant is /// media-only, so its extension is always the default. Wrap with -/// [`Filter`](super::Filter) to narrow the rendition set before handing the +/// [`Select`](super::Select) to narrow the rendition set before handing the /// stream to an exporter. /// /// The variants are an implementation detail: drive it through the [`Stream`] diff --git a/rs/moq-mux/src/catalog/filter.rs b/rs/moq-mux/src/catalog/filter.rs deleted file mode 100644 index d5ac6e956..000000000 --- a/rs/moq-mux/src/catalog/filter.rs +++ /dev/null @@ -1,362 +0,0 @@ -//! Hard-match rendition filter. -//! -//! [`Filter`] wraps any [`Stream`] and drops renditions that don't satisfy a -//! [`FilterVideo`] / [`FilterAudio`]. Matching is exact: a `name` constraint -//! keeps only the rendition with that key, a `codec` constraint keeps only -//! renditions whose codec family matches. Multiple constraints intersect. - -use std::task::Poll; - -use hang::catalog::{AudioCodecKind, VideoCodecKind}; - -use super::Stream; -use super::hang::{Catalog, CatalogExt}; - -/// Hard-match criteria for video renditions. -#[derive(Debug, Default, Clone)] -pub struct FilterVideo { - /// Keep only the rendition with this exact name. - pub name: Option, - /// Keep only renditions whose codec family matches. - pub codec: Option, -} - -/// Hard-match criteria for audio renditions. -#[derive(Debug, Default, Clone)] -pub struct FilterAudio { - /// Keep only the rendition with this exact name. - pub name: Option, - /// Keep only renditions whose codec family matches. - pub codec: Option, -} - -/// Shared state behind a [`Filter`]. -/// -/// `epoch` advances on every setter so [`Filter::poll_next`] can tell whether -/// the criteria changed since the last emit. -#[derive(Debug, Default, Clone)] -struct FilterState { - video: Option, - audio: Option, - epoch: u64, -} - -/// A [`Stream`] that drops renditions failing a [`FilterVideo`] / [`FilterAudio`]. -/// -/// Selection criteria live behind a [`kio::Producer`], so calls to -/// [`set_video`](Self::set_video) / [`set_audio`](Self::set_audio) wake any -/// pending `poll_next` instead of silently waiting for the next upstream -/// snapshot. -pub struct Filter { - inner: S, - state: kio::Producer, - state_consumer: kio::Consumer, - /// Last raw snapshot from `inner`, retained so a setter between snapshots - /// can re-apply without polling upstream. - last_input: Option>, - /// Epoch we already emitted against. - last_epoch: u64, - /// True once `inner` has handed us a snapshot we haven't emitted yet. - fresh_input: bool, -} - -impl Filter { - pub fn new(inner: S) -> Self { - let state = kio::Producer::new(FilterState::default()); - let state_consumer = state.consume(); - Self { - inner, - state, - state_consumer, - last_input: None, - last_epoch: 0, - fresh_input: false, - } - } - - /// Set or clear the video filter. Pass `None` to clear. - pub fn set_video(&mut self, filter: impl Into>) { - self.update(|s| s.video = filter.into()); - } - - /// Set or clear the audio filter. Pass `None` to clear. - pub fn set_audio(&mut self, filter: impl Into>) { - self.update(|s| s.audio = filter.into()); - } - - fn update(&self, f: impl FnOnce(&mut FilterState)) { - // `write()` only errors when the producer is closed, which can't happen - // while `self` holds the only producer handle. - let Ok(mut state) = self.state.write() else { - return; - }; - f(&mut state); - state.epoch = state.epoch.wrapping_add(1); - // Mut::drop wakes the paired consumer waiters here. - } -} - -impl Stream for Filter { - type Ext = S::Ext; - - fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { - let inner_eof = loop { - match self.inner.poll_next(waiter)? { - Poll::Ready(Some(snapshot)) => { - self.last_input = Some(snapshot); - self.fresh_input = true; - } - Poll::Ready(None) => break true, - Poll::Pending => break false, - } - }; - - let last_epoch = self.last_epoch; - let fresh_input = self.fresh_input; - let last_input = self.last_input.clone(); - - let polled = self.state_consumer.poll(waiter, |state| { - let filter_changed = state.epoch != last_epoch; - if !fresh_input && !filter_changed { - return Poll::Pending; - } - let Some(input) = last_input.clone() else { - return Poll::Pending; - }; - let emit = apply(input, state.video.as_ref(), state.audio.as_ref()); - Poll::Ready((emit, state.epoch)) - }); - - match polled { - Poll::Ready(Ok((emit, epoch))) => { - self.last_epoch = epoch; - self.fresh_input = false; - // End with upstream: if this is the final snapshot (inner already EOF'd), - // drop the retained input so a later filter change can't revive the stream - // after it has emitted its last value. - if inner_eof { - self.last_input = None; - } - Poll::Ready(Ok(Some(emit))) - } - Poll::Ready(Err(_)) => Poll::Ready(Ok(None)), - Poll::Pending => { - // EOF is terminal: once `inner` is exhausted and there's nothing fresh to - // emit, finish and drop the retained input so a post-EOF setter can't make - // the closure emit again (a still-pending snapshot returns Ready above). - if inner_eof { - self.last_input = None; - Poll::Ready(Ok(None)) - } else { - Poll::Pending - } - } - } - } -} - -/// Apply the active video / audio filters to a raw snapshot, dropping -/// renditions that don't match. Axes with no filter pass through unchanged. -fn apply( - mut catalog: Catalog, - video: Option<&FilterVideo>, - audio: Option<&FilterAudio>, -) -> Catalog { - if let Some(filter) = video { - catalog.video.renditions.retain(|name, config| { - if let Some(want) = &filter.name - && want != name - { - return false; - } - if let Some(want) = filter.codec - && config.codec.kind() != want - { - return false; - } - true - }); - } - if let Some(filter) = audio { - catalog.audio.renditions.retain(|name, config| { - if let Some(want) = &filter.name - && want != name - { - return false; - } - if let Some(want) = filter.codec - && config.codec.kind() != want - { - return false; - } - true - }); - } - catalog -} - -#[cfg(test)] -mod test { - use std::collections::BTreeMap; - - use hang::catalog::{AudioCodec, AudioConfig, Container, H264, VideoConfig}; - - use super::*; - - struct Once(Option); - - impl Stream for Once { - type Ext = (); - - fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { - Poll::Ready(Ok(self.0.take())) - } - } - - /// A still-live stream: yields its snapshot once, then parks (never EOFs). Models a - /// real upstream that stays open so post-snapshot retargeting is exercised without - /// tripping the end-with-upstream path. - struct Live(Option); - - impl Stream for Live { - type Ext = (); - - fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { - match self.0.take() { - Some(catalog) => Poll::Ready(Ok(Some(catalog))), - None => Poll::Pending, - } - } - } - - fn h264(name: &str) -> (String, VideoConfig) { - let mut config = VideoConfig::new(H264 { - profile: 0x42, - constraints: 0, - level: 0x1e, - inline: false, - }); - config.coded_width = Some(640); - config.coded_height = Some(360); - config.bitrate = Some(500_000); - config.framerate = Some(30.0); - config.container = Container::Legacy; - (name.to_string(), config) - } - - fn opus(name: &str) -> (String, AudioConfig) { - let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, 2); - config.bitrate = Some(128_000); - config.container = Container::Legacy; - (name.to_string(), config) - } - - fn catalog_with(video: Vec<(String, VideoConfig)>, audio: Vec<(String, AudioConfig)>) -> Catalog { - let mut c = Catalog::default(); - c.video.renditions = BTreeMap::from_iter(video); - c.audio.renditions = BTreeMap::from_iter(audio); - c - } - - #[test] - fn codec_filter_keeps_matching() { - let mut hd = h264("hd"); - hd.1.codec = hang::catalog::VP9 { - profile: 0, - level: 10, - bit_depth: 8, - chroma_subsampling: 1, - color_primaries: 1, - transfer_characteristics: 1, - matrix_coefficients: 1, - full_range: false, - } - .into(); - let snapshot = catalog_with(vec![h264("lo"), hd], vec![]); - - let mut f = Filter::new(Once(Some(snapshot))); - f.set_video(FilterVideo { - codec: Some(VideoCodecKind::H264), - ..Default::default() - }); - - let out = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("expected snapshot, got {other:?}"), - }; - assert_eq!(out.video.renditions.keys().collect::>(), vec!["lo"]); - } - - #[test] - fn name_filter_exact() { - let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); - let mut f = Filter::new(Once(Some(snapshot))); - f.set_video(FilterVideo { - name: Some("hi".into()), - ..Default::default() - }); - let out = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("got {other:?}"), - }; - assert_eq!(out.video.renditions.keys().collect::>(), vec!["hi"]); - } - - #[test] - fn audio_filter_independent_of_video() { - let snapshot = catalog_with(vec![h264("hi")], vec![opus("en"), opus("es")]); - let mut f = Filter::new(Once(Some(snapshot))); - f.set_audio(FilterAudio { - name: Some("es".into()), - ..Default::default() - }); - let out = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("got {other:?}"), - }; - assert_eq!(out.video.renditions.keys().collect::>(), vec!["hi"]); - assert_eq!(out.audio.renditions.keys().collect::>(), vec!["es"]); - } - - #[test] - fn ends_after_upstream_eof() { - let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); - let mut f = Filter::new(Once(Some(snapshot))); - - // First poll emits the filtered snapshot. - assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(Some(_))))); - // Upstream is exhausted, so the stream ends rather than parking forever. - assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); - - // EOF is terminal: a filter change after the end must not revive the stream. - f.set_video(FilterVideo { - name: Some("hi".into()), - ..Default::default() - }); - assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); - } - - #[test] - fn set_video_after_snapshot_reemits() { - // A live (not-yet-EOF) upstream, so the retarget re-applies to the retained snapshot. - let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); - let mut f = Filter::new(Live(Some(snapshot))); - - let first = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("got {other:?}"), - }; - assert_eq!(first.video.renditions.len(), 2); - - f.set_video(FilterVideo { - name: Some("hi".into()), - ..Default::default() - }); - - let again = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("expected re-emit, got {other:?}"), - }; - assert_eq!(again.video.renditions.keys().collect::>(), vec!["hi"]); - } -} diff --git a/rs/moq-mux/src/catalog/mod.rs b/rs/moq-mux/src/catalog/mod.rs index 06c09c916..f7a4a1612 100644 --- a/rs/moq-mux/src/catalog/mod.rs +++ b/rs/moq-mux/src/catalog/mod.rs @@ -15,23 +15,24 @@ //! //! On the consume side, [`Consumer`] is the unified entry point: it //! subscribes to whichever catalog track `format` advertises and yields -//! [`Catalog`](hang::Catalog) snapshots. Wrap it with [`Filter`] (hard -//! match on name / codec family) to narrow the set before handing it to an -//! exporter; both also implement [`Stream`] so they compose either direction. +//! [`Catalog`](hang::Catalog) snapshots. Wrap it with [`Select`] (driven by a +//! [`select::Broadcast`](crate::select::Broadcast)) to narrow the set before +//! handing it to an exporter; both also implement [`Stream`] so they compose +//! either direction. pub mod hang; pub mod msf; mod consumer; -mod filter; mod format; mod producer; +mod select; mod stream; mod tracks; pub use consumer::Consumer; -pub use filter::{Filter, FilterAudio, FilterVideo}; pub use format::*; pub use producer::{Guard, Producer}; +pub use select::Select; pub use stream::Stream; pub use tracks::{AudioTrack, VideoTrack}; diff --git a/rs/moq-mux/src/catalog/select.rs b/rs/moq-mux/src/catalog/select.rs new file mode 100644 index 000000000..febce02d2 --- /dev/null +++ b/rs/moq-mux/src/catalog/select.rs @@ -0,0 +1,88 @@ +//! Rendition-selecting catalog stream. +//! +//! [`Select`] wraps any [`Stream`] and applies a [`select::Broadcast`](crate::select::Broadcast) +//! to each snapshot, dropping renditions that aren't selected before handing the +//! catalog to an exporter. + +use std::task::{Poll, ready}; + +use super::Stream; +use super::hang::Catalog; +use crate::select; + +/// A [`Stream`] that keeps only the renditions a [`select::Broadcast`] selects. +/// +/// The selection is fixed at construction; every snapshot from the inner stream is +/// narrowed by it. Build one with [`Stream::select`](super::Stream::select) or +/// [`Select::new`]. +pub struct Select { + inner: S, + selection: select::Broadcast, +} + +impl Select { + /// Wrap `inner`, narrowing every snapshot by `selection`. + pub fn new(inner: S, selection: select::Broadcast) -> Self { + Self { inner, selection } + } +} + +impl Stream for Select { + type Ext = S::Ext; + + fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { + let next = ready!(self.inner.poll_next(waiter))?; + Poll::Ready(Ok(next.map(|mut catalog| { + self.selection.retain(&mut catalog); + catalog + }))) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use hang::catalog::{Container, H264, VideoConfig}; + + use super::super::hang::Catalog; + use super::*; + + /// A one-shot stream: yields its snapshot once, then ends. + struct Once(Option); + + impl Stream for Once { + type Ext = (); + + fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { + Poll::Ready(Ok(self.0.take())) + } + } + + fn h264(name: &str) -> (String, VideoConfig) { + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + config.container = Container::Legacy; + (name.to_string(), config) + } + + #[test] + fn narrows_each_snapshot() { + let mut catalog = Catalog::default(); + catalog.video.renditions = BTreeMap::from_iter(vec![h264("lo"), h264("hi")]); + + let selection = select::Broadcast::default().video(select::Video::default().name("hi")); + let mut stream = Once(Some(catalog)).select(selection); + + let out = match stream.poll_next(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("expected snapshot, got {other:?}"), + }; + assert_eq!(out.video.renditions.keys().collect::>(), vec!["hi"]); + assert!(matches!(stream.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + } +} diff --git a/rs/moq-mux/src/catalog/stream.rs b/rs/moq-mux/src/catalog/stream.rs index 10690bca1..8c4a351c0 100644 --- a/rs/moq-mux/src/catalog/stream.rs +++ b/rs/moq-mux/src/catalog/stream.rs @@ -2,7 +2,7 @@ //! //! [`Stream`] yields a sequence of [`Catalog`](super::hang::Catalog) snapshots. Both the //! raw [`Consumer`](super::Consumer) and the rendition-selecting -//! [`Filter`](super::Filter) wrapper implement it, so exporters can be written +//! [`Select`](super::Select) wrapper implement it, so exporters can be written //! against the trait and the caller picks the selection policy. //! //! The yielded catalog carries the application extension `E` (defaulting to @@ -11,7 +11,7 @@ use std::task::Poll; -use super::Filter; +use super::Select; use super::hang::{Catalog, CatalogExt}; /// A stream of catalog snapshots. @@ -36,12 +36,12 @@ pub trait Stream: Send + 'static { async move { kio::wait(|waiter| self.poll_next(waiter)).await } } - /// Wrap this stream in a [`Filter`] that drops renditions which don't - /// match a hard-match criterion (name or codec family). - fn filter(self) -> Filter + /// Wrap this stream in a [`Select`] that drops every rendition `selection` + /// doesn't keep. + fn select(self, selection: crate::select::Broadcast) -> Select where Self: Sized, { - Filter::new(self) + Select::new(self, selection) } } diff --git a/rs/moq-mux/src/codec/h264/export.rs b/rs/moq-mux/src/codec/h264/export.rs index 3929c68e4..932f1be14 100644 --- a/rs/moq-mux/src/codec/h264/export.rs +++ b/rs/moq-mux/src/codec/h264/export.rs @@ -53,11 +53,10 @@ struct Avc1Convert { impl Export { /// Subscribe to `broadcast` and emit an Annex-B H.264 byte stream. /// - /// `catalog` is expected to be narrowed to a single H.264 rendition (e.g. - /// `consumer.filter()` with `codec = H264` then `.target()` for ABR - /// selection). Renditions of other codecs are ignored; if multiple H.264 - /// renditions appear in a snapshot, the first by BTreeMap order wins and - /// a warning is logged. + /// `catalog` is expected to be narrowed to a single H.264 rendition by name (e.g. + /// `consumer.select(select::Broadcast::default().video(select::Video::default().name("hd")))`). + /// Renditions of other codecs are ignored; if multiple H.264 renditions appear + /// in a snapshot, the first by BTreeMap order wins and a warning is logged. pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { Self { broadcast, @@ -133,7 +132,7 @@ impl Export { tracing::warn!( count = picked.len(), "multiple H.264 renditions in catalog snapshot; using the first by name. \ - Narrow with catalog::Filter to pick one explicitly." + Narrow with catalog::Select to pick one explicitly." ); } diff --git a/rs/moq-mux/src/codec/h265/export.rs b/rs/moq-mux/src/codec/h265/export.rs index be877d8ab..ee14fbe1a 100644 --- a/rs/moq-mux/src/codec/h265/export.rs +++ b/rs/moq-mux/src/codec/h265/export.rs @@ -124,7 +124,7 @@ impl Export { tracing::warn!( count = picked.len(), "multiple H.265 renditions in catalog snapshot; using the first by name. \ - Narrow with catalog::Filter to pick one explicitly." + Narrow with catalog::Select to pick one explicitly." ); } diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index ba98f2b2e..fabe9ee55 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -32,7 +32,7 @@ use crate::container::{Frame, Timestamp}; /// a media fragment begins at a sync sample, and its presentation duration. A /// segmenting consumer (e.g. an HLS/LL-HLS packager) needs that to map fragments /// onto segments and parts; narrow the catalog to a single rendition with -/// [`catalog::Filter`](crate::catalog::Filter) so the fragments belong to one track. +/// [`catalog::Select`](crate::catalog::Select) so the fragments belong to one track. pub struct Export { broadcast: moq_net::BroadcastConsumer, catalog: Option, @@ -104,7 +104,7 @@ impl Export { /// /// `catalog` is any [`Stream`] of catalog snapshots, typically a /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or wrapped in - /// [`catalog::Filter`](crate::catalog::Filter) to narrow the rendition set. + /// [`catalog::Select`](crate::catalog::Select) to narrow the rendition set. pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { Self { broadcast, diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 4f96bcc6e..7259675d7 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -1,7 +1,7 @@ use bytes::{Bytes, BytesMut}; use hang::catalog::{AAC, AudioCodec, AudioConfig, Container, H264, H265, VP9, VideoCodec, VideoConfig}; use mp4_atom::{Any, Atom, DecodeMaybe, Encode, Mdat, Moof, Moov, Trak}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use super::Error; use crate::Result; @@ -31,9 +31,16 @@ pub struct Import { /// The catalog being produced catalog: crate::catalog::Producer, + // Which track roles to publish. `None` imports every supported track. + select: Option, + // A lookup to tracks in the broadcast tracks: HashMap, + // Track ids skipped by `select`, so their moof fragments are ignored rather + // than treated as referencing an unknown track. + skipped: HashSet, + // The moov atom at the start of the file. moov: Option, @@ -78,7 +85,9 @@ impl Import { pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { Self { catalog, + select: None, tracks: HashMap::default(), + skipped: HashSet::default(), moov: None, moof: None, moof_size: 0, @@ -87,6 +96,28 @@ impl Import { } } + /// Restrict which track roles are published. + /// + /// fMP4 import selects whole roles: a [`select::Broadcast`](crate::select::Broadcast) + /// that doesn't select video drops every video track, and likewise for audio. The + /// rendition-level narrowing inside a [`select::Video`](crate::select::Video) / + /// [`select::Audio`](crate::select::Audio) (by name or codec) is a consume-side + /// concern (see [`catalog::Select`](crate::catalog::Select)) and is ignored here. + /// Without this, every supported track is imported. + pub fn with_select(mut self, select: crate::select::Broadcast) -> Self { + self.select = Some(select); + self + } + + /// Whether `kind` is selected for import (every role when unset). + fn selects(&self, kind: &TrackKind) -> bool { + match (&self.select, kind) { + (None, _) => true, + (Some(select), TrackKind::Video) => select.has_video(), + (Some(select), TrackKind::Audio) => select.has_audio(), + } + } + /// Decode a buffer of bytes. pub fn decode(&mut self, data: &[u8]) -> Result<()> { self.buffer.extend_from_slice(data); @@ -156,30 +187,40 @@ impl Import { let handler = &trak.mdia.hdlr.handler; let suffix = ".m4s"; + let kind = match handler.as_ref() { + b"vide" => TrackKind::Video, + b"soun" => TrackKind::Audio, + b"sbtl" => return Err(Error::UnsupportedSubtitle.into()), + handler => { + let mut buf = [0u8; 4]; + buf[..handler.len().min(4)].copy_from_slice(&handler[..handler.len().min(4)]); + return Err(Error::UnknownTrackHandler(buf).into()); + } + }; + + // Drop tracks whose role isn't selected before minting or publishing them; their + // moof fragments are ignored in `extract`. + if !self.selects(&kind) { + self.skipped.insert(track_id); + continue; + } + // Declare the track at the fMP4's native timescale. Frame timestamps are // emitted at this same scale (see below), so they satisfy the track's // timescale invariant and ride the wire for the relay, redundant with the // timing already inside each CMAF fragment. let track = self.broadcast.unique_track(suffix)?; - let kind = match handler.as_ref() { - b"vide" => { + match kind { + TrackKind::Video => { let config = self.init_video(trak, &moov)?; catalog.video.renditions.insert(track.name().to_string(), config); - TrackKind::Video } - b"soun" => { + TrackKind::Audio => { let config = self.init_audio(trak, &moov)?; catalog.audio.renditions.insert(track.name().to_string(), config); - TrackKind::Audio } - b"sbtl" => return Err(Error::UnsupportedSubtitle.into()), - handler => { - let mut buf = [0u8; 4]; - buf[..handler.len().min(4)].copy_from_slice(&handler[..handler.len().min(4)]); - return Err(Error::UnknownTrackHandler(buf).into()); - } - }; + } self.tracks.insert( track_id, @@ -410,7 +451,12 @@ impl Import { // Loop over all of the traf boxes in the moof. for traf in &moof.traf { let track_id = traf.tfhd.track_id; - let track = self.tracks.get_mut(&track_id).ok_or(Error::UnknownTrack(track_id))?; + let track = match self.tracks.get_mut(&track_id) { + Some(track) => track, + // A fragment for a track `select` dropped: ignore it. + None if self.skipped.contains(&track_id) => continue, + None => return Err(Error::UnknownTrack(track_id).into()), + }; // Find the track information in the moov let trak = moov diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index 6795b5fd8..75b33a3e0 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -26,6 +26,26 @@ fn run_fmp4(data: &[u8]) -> crate::catalog::hang::Catalog { catalog.snapshot() } +fn run_fmp4_select(data: &[u8], select: crate::select::Broadcast) -> crate::catalog::hang::Catalog { + let mut broadcast = moq_net::Broadcast::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + + let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.clone()).with_select(select); + + // A dropped track's moof fragments must be skipped, not raise `UnknownTrack`. + // (The test files end on a malformed fragment, so other decode errors are expected + // and ignored; only `UnknownTrack` would mean the skip path regressed.) + let buf = bytes::BytesMut::from(data); + if let Err(err) = fmp4.decode(&buf) { + assert!( + !matches!(err, crate::Error::Cmaf(crate::container::fmp4::Error::UnknownTrack(_))), + "a skipped track's fragment raised UnknownTrack: {err:?}" + ); + } + + catalog.snapshot() +} + fn decode_init(init: &[u8]) -> (mp4_atom::Ftyp, mp4_atom::Moov) { let mut cursor = std::io::Cursor::new(init); let ftyp = mp4_atom::Ftyp::decode(&mut cursor).expect("invalid ftyp"); @@ -54,6 +74,38 @@ fn test_bbb_catalog() { assert!(matches!(audio.container, Container::Cmaf { .. })); } +#[test] +fn select_video_only() { + use crate::select::{Broadcast, Video}; + + let data = include_bytes!("test_data/bbb.mp4"); + let catalog = run_fmp4_select(data, Broadcast::default().video(Video::default())); + + // The muxed audio track is dropped; only video is published. + assert_eq!(catalog.video.renditions.len(), 1); + assert!(catalog.audio.renditions.is_empty()); +} + +#[test] +fn select_audio_only() { + use crate::select::{Audio, Broadcast}; + + let data = include_bytes!("test_data/bbb.mp4"); + let catalog = run_fmp4_select(data, Broadcast::default().audio(Audio::default())); + + assert!(catalog.video.renditions.is_empty()); + assert_eq!(catalog.audio.renditions.len(), 1); +} + +#[test] +fn select_nothing_publishes_nothing() { + let data = include_bytes!("test_data/bbb.mp4"); + let catalog = run_fmp4_select(data, crate::select::Broadcast::default()); + + assert!(catalog.video.renditions.is_empty()); + assert!(catalog.audio.renditions.is_empty()); +} + #[test] fn test_bbb_init_roundtrip() { let data = include_bytes!("test_data/bbb.mp4"); diff --git a/rs/moq-mux/src/container/mkv/export.rs b/rs/moq-mux/src/container/mkv/export.rs index da28ee9cd..c39e65c54 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -157,7 +157,7 @@ impl Export { /// /// `catalog` is any [`Stream`] of catalog snapshots, typically a /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or wrapped in - /// [`catalog::Filter`](crate::catalog::Filter) to narrow the rendition set. + /// [`catalog::Select`](crate::catalog::Select) to narrow the rendition set. pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { Self { broadcast, diff --git a/rs/moq-mux/src/lib.rs b/rs/moq-mux/src/lib.rs index 3e6e87518..f76cacb65 100644 --- a/rs/moq-mux/src/lib.rs +++ b/rs/moq-mux/src/lib.rs @@ -14,6 +14,8 @@ //! the JSON manifest listing every track and how to decode it. //! - [`import`](mod@import) is the front door for callers who only have //! a format string. It picks the right concrete importer for you. +//! - [`select`] picks which renditions of a broadcast to keep, on either +//! the import or the consume side. pub mod catalog; mod clock; @@ -21,6 +23,7 @@ pub mod codec; pub mod container; mod error; pub mod import; +pub mod select; pub use clock::Clock; pub use error::*; diff --git a/rs/moq-mux/src/select.rs b/rs/moq-mux/src/select.rs new file mode 100644 index 000000000..66e4f4fc0 --- /dev/null +++ b/rs/moq-mux/src/select.rs @@ -0,0 +1,249 @@ +//! Track selection. +//! +//! [`Broadcast`] picks which renditions of a broadcast to keep. It is purely +//! additive: a default [`Broadcast`] selects *nothing*, and you opt a role in with +//! [`video`](Broadcast::video) / [`audio`](Broadcast::audio). Within an opted-in +//! role, an empty field matches everything; listing values keeps renditions matching +//! any one of them (a union within a field, intersected across fields). +//! +//! The same [`Broadcast`] drives selection at either end of the pipeline: narrowing +//! a published catalog on the consume side (see [`catalog::Select`](crate::catalog::Select)), +//! or choosing which tracks to publish on the import side. + +use hang::catalog::{AudioCodecKind, AudioConfig, VideoCodecKind, VideoConfig}; + +use crate::catalog::hang::{Catalog, CatalogExt}; + +/// Which renditions of a broadcast to keep. +/// +/// Defaults to selecting nothing. Opt a role in with [`video`](Self::video) / +/// [`audio`](Self::audio); an unselected role is dropped entirely. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct Broadcast { + video: Option