diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c68f6f70c..9888a528d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -32,6 +32,13 @@ updates: cargo: patterns: - "*" + ignore: + # reqwest-middleware 0.5 requires reqwest 0.13, but http-cache-reqwest's + # latest stable (0.16) still pins reqwest 0.12 / reqwest-middleware 0.4. + # The only pairing is http-cache-reqwest 1.0.0-alpha (pre-release), so we + # hold reqwest-middleware at 0.4 until that line ships stable. + - dependency-name: "reqwest-middleware" + versions: [">=0.5"] - package-ecosystem: "bun" directory: "/" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4c428efe6..3ce1b0a97 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -6,8 +6,12 @@ permissions: on: pull_request: + # `closed` is here only so merging/closing a PR cancels its in-flight run + # via the concurrency group below; the job itself is skipped on close. + types: [opened, synchronize, reopened, closed] -# Cancel in-flight runs when a new commit lands on the same PR. +# Cancel in-flight runs when a new commit lands on the same PR, or when the PR +# is merged/closed (the close event shares this group and supersedes the build). concurrency: group: check-${{ github.ref }} cancel-in-progress: true @@ -15,6 +19,9 @@ concurrency: jobs: check: name: Check + # Skip the actual work on close/merge: this run exists only to cancel the + # superseded in-flight build via the concurrency group above. + if: github.event.action != 'closed' # Trusted events run on the moq-dev self-hosted A1 runner (warm /nix/store + # persistent CARGO_TARGET_DIR). Fork PRs fall back to GitHub-hosted ARM so # untrusted code never runs on the box. diff --git a/CLAUDE.md b/CLAUDE.md index 5cfcf5db1..aa193181c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,11 +215,14 @@ Changes in one area usually need matching updates elsewhere, including docs. If | `rs/moq-token` | `js/token` | | `rs/moq-relay` config/behavior | `doc/bin/relay/` | | `rs/moq-cli` | `doc/bin/cli.md` | +| `rs/moq-token-cli` | `doc/bin/relay/auth.md`, `doc/lib/rs/crate/moq-token.md`, `doc/lib/rs/index.md` | | `rs/moq-gst` | `doc/bin/gstreamer.md` | | `js/{watch,publish}` UI/API | `demo/web` if it consumes the API | For `swift/`, the wrapper re-exports `moq-ffi` records/enums via typealias, so new catalog/audio *fields* flow through automatically. Only a new FFI *method* (or a renamed/removed one) needs a matching change in the de-prefixed `Sources/Moq` wrapper. +**When a command-line tool's interface changes (a flag, argument, subcommand, or positional renamed/added/removed/reordered), update every doc that shows an example invocation, not just the tool's primary page.** Sample commands for `moq-cli`, `moq-relay`, and `moq-token-cli` are scattered across `doc/bin/`, `doc/lib/`, `doc/setup/`, and `doc/concept/`, plus the `justfile`s under `demo/`. Grep the whole repo for the binary name and reconcile each hit against the binary's `--help`. A stale example that no longer parses is worse than no example. + ## Branch Targeting Two long-lived branches: diff --git a/Cargo.lock b/Cargo.lock index a42c6f416..5d97a772c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -717,9 +717,9 @@ dependencies = [ [[package]] name = "boytacean" -version = "0.11.5" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44a774c104e597a24d60ea21e7b949ad19e4aa7a39590dd1467f517066bbed42" +checksum = "333afc1e8f6a0bbba0a916346a2fca124c153b6ec42f5c8d795c05126503ee0b" dependencies = [ "boytacean-common", "boytacean-encoding", @@ -731,15 +731,15 @@ dependencies = [ [[package]] name = "boytacean-common" -version = "0.11.5" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d51a26a3d429ea3a419c67122eff610fbc96e59d75544f77d677f4e39c094940" +checksum = "9fe79b9ef2249700e02747de11cbc048870767705b8c31615a820bda6d94f81f" [[package]] name = "boytacean-encoding" -version = "0.11.5" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2bfc97163003de6037ab40296d0f5c2e50fe1b76c626e75cedd8f2017346b13" +checksum = "e7edc2850784c0e2b0a55e7d4e7823ef60c4493be021c13f6010fca19d75edfa" dependencies = [ "boytacean-common", "boytacean-hashing", @@ -747,9 +747,9 @@ dependencies = [ [[package]] name = "boytacean-hashing" -version = "0.11.5" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4d1f207e18fc7559267427e86a9e45997d242ea6fa10ae41a617053eb5d9eb4" +checksum = "221540d0cb3bc7222b46c061e330ce05622ed739ed2f7247864ab7ec5f63dfa3" dependencies = [ "boytacean-common", ] @@ -4025,7 +4025,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.7.33" +version = "0.7.34" dependencies = [ "anyhow", "axum", @@ -4163,6 +4163,7 @@ dependencies = [ "scuffle-h265", "serde", "serde_json", + "serde_with", "thiserror 2.0.18", "tokio", "tracing", @@ -5991,9 +5992,9 @@ dependencies = [ [[package]] name = "qmux" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3c294405b759583a70c2fa68fd506f094baa5723412f578151b4ed56a87086" +checksum = "f466003646aebc080ad4237b5a4c00ea435eaba1be04496ab780ced381c3395f" dependencies = [ "bytes", "futures", diff --git a/Cargo.toml b/Cargo.toml index 3cc7ecd0f..60b809301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,7 @@ moq-token = { version = "0.6", path = "rs/moq-token" } # Standalone crate (moq-dev/vaapi); vendored from cros-libva + cros-codecs. moq-vaapi = "0.0.2" moq-video = { version = "0.0.4", path = "rs/moq-video" } -qmux = { version = "0.1.3", default-features = false } +qmux = { version = "0.2", default-features = false } serde = { version = "1", features = ["derive"] } tokio = "1.48" web-async = { version = "0.1.4", features = ["tracing"] } diff --git a/bun.lock b/bun.lock index 316c50b2b..87147d54b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "moq", "devDependencies": { - "@biomejs/biome": "^2.4.15", - "concurrently": "^9.2.1", + "@biomejs/biome": "^2.4.16", + "concurrently": "^10.0.3", "publint": "^0.3.21", "remark-cli": "^12.0.1", "remark-frontmatter": "^5.0.0", @@ -28,7 +28,7 @@ "devDependencies": { "esbuild": "^0.28.0", "typescript": "^6.0.3", - "vite": "^8.0.14", + "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", }, }, @@ -41,15 +41,16 @@ "@moq/watch": "workspace:^", }, "devDependencies": { - "@tailwindcss/typography": "^0.5.16", + "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.0", "esbuild": "^0.28.0", "highlight.js": "^11.11.1", + "open": "^10.2.0", "solid-element": "^1.9.1", "solid-js": "^1.9.13", "tailwindcss": "^4.1.13", "typescript": "^6.0.3", - "vite": "^8.0.14", + "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", }, }, @@ -58,7 +59,7 @@ "version": "0.1.0", "devDependencies": { "vitepress": "^1.6.4", - "wrangler": "^4.95.0", + "wrangler": "^4.99.0", }, }, "js/clock": { @@ -73,7 +74,7 @@ "@moq/net": "workspace:*", }, "devDependencies": { - "@types/node": "^25.9.1", + "@types/node": "^25.9.2", "typescript": "^6.0.3", }, }, @@ -143,7 +144,7 @@ "rimraf": "^6.1.3", "solid-js": "^1.9.13", "typescript": "^6.0.3", - "vite": "^8.0.14", + "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", }, }, @@ -169,7 +170,7 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.1", + "@types/node": "^25.9.2", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", "typescript": "^6.0.3", @@ -195,7 +196,7 @@ "esbuild": "^0.28.0", "rimraf": "^6.1.3", "typescript": "^6.0.3", - "vite": "^8.0.14", + "vite": "^8.0.16", }, }, "js/signals": { @@ -203,14 +204,14 @@ "version": "0.1.9", "devDependencies": { "@types/bun": "^1.3.11", - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "react": "^19.0.0", "rimraf": "^6.1.3", "solid-js": "^1.9.13", "typescript": "^6.0.3", }, "peerDependencies": { - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "react": "^19.0.0", "solid-js": "^1.9.13", }, @@ -228,13 +229,13 @@ }, "dependencies": { "@hexagon/base64": "^2.0.4", - "commander": "^14.0.2", + "commander": "^15.0.0", "jose": "^6.2.3", "zod": "^4.4.3", }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.1", + "@types/node": "^25.9.2", "rimraf": "^6.1.3", "typescript": "^6.0.3", }, @@ -260,7 +261,7 @@ "esbuild": "^0.28.0", "rimraf": "^6.1.3", "typescript": "^6.0.3", - "vite": "^8.0.14", + "vite": "^8.0.16", }, }, }, @@ -340,37 +341,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.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], + "@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/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="], + "@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-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], "@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.20260526.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/pR3GH3gfv0PUp7DjI8v0aAIDOqFwibq4bg5xT7TZgcVdBV/cJQWckdXCMqiRtHiawLwogUX00EIOINkYJ1Zqg=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260609.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-AK8tYLQm+8BqQMzjZ55ZfuhfIm1eCkj+Ykxz6kWXojdACwjjU03MrwdM9fBDdgzU3upXOs4e1scOFHySlfVQjA=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260526.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rcyu0iANYfaiezKh3Mcao1O4IIgVfQldxduiL5TZT1sP0NIeRY4YReSTrzPxNnXxSYaIqaqRHMcHbUM/ic4knA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260609.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4kKXfr7ZHU6xQ/R9ShdSuj1A1bEouoRcHzUWdjnuMPBlRsAAVanlxAVYISotFUulLEinayOpRFbhpsfwzrpSSw=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260526.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5EZAEnlLwa9oGJRo8Nd3iY5Wcd9ROGNNG90xNIGp8MEjj8v2jTn42NC47fCZKFdnLj3+S+vWEhu1x0GVJnALjA=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260609.1", "", { "os": "linux", "cpu": "x64" }, "sha512-T2Ebir2OPHAvvZ0HUh5mi1lN8q30sVi4lf7LIpc28AHoWtoOmJ0jA5AJK4IYJm1MKEbBldq+QsckaHOCQFmRpQ=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260526.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-X/YBQXeXFeCN7QTStoWrATEBc9WKl7PIqkw/dQkjyJ72gh3rkLe0+Xkzp3wO7gtxTDQMa7NPGy1W4+sdMf8q1g=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260609.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-INfcYoSsKqEIvPL69/3RkqYoP8WUR0VEN6loWN/3tekXLoJrVOj3E5NjIetsdS8MJN6zc3st/ae4bMuWRRzoDg=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260526.1", "", { "os": "win32", "cpu": "x64" }, "sha512-R+tqpFFdcfZIljx8fIW9rj9fRTtDgfoA2yonsfAGa6e8snrmr+38mdFHtkRC0D3UyZpn/hOtmXiUBfdX2gMR7Q=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260609.1", "", { "os": "win32", "cpu": "x64" }, "sha512-EWhfxKI1aqUr7S8xuGxgmRCumEzB8iSsCIz6oEqJN+3pZuW3EWiKDGFW4EY1BmwNINLW1eO5VMGYb8Fj6FVYxA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -570,7 +571,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.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -582,35 +583,35 @@ "@publint/pack": ["@publint/pack@0.1.4", "", {}, "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="], + "@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-x64": ["@rolldown/binding-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w=="], + "@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-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw=="], + "@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-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ=="], + "@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-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ=="], + "@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-musl": ["@rolldown/binding-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw=="], + "@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-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ=="], + "@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-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A=="], + "@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-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], @@ -718,7 +719,7 @@ "@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/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + "@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=="], @@ -758,9 +759,9 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], - "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/supports-color": ["@types/supports-color@8.1.3", "", {}, "sha512-Hy6UMpxhE3j1tLpl27exp1XqHD7n8chAiNPzWfz16LPZoMMoSc4dzLl6w9qijkEb/r5O1ozdu1CWGA2L83ZeZg=="], @@ -818,7 +819,7 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], @@ -862,13 +863,15 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "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=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -886,7 +889,7 @@ "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], - "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=="], + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "cmake-js": ["cmake-js@8.0.0", "", { "dependencies": { "debug": "^4.4.3", "fs-extra": "^11.3.3", "node-api-headers": "^1.8.0", "rc": "1.2.8", "semver": "^7.7.3", "tar": "^7.5.6", "url-join": "^4.0.1", "which": "^6.0.0", "yargs": "^17.7.2" }, "bin": { "cmake-js": "bin/cmake-js" } }, "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg=="], @@ -900,13 +903,13 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "component-register": ["component-register@0.8.8", "", {}, "sha512-djhwcxjY+X9dacaYUEOkOm7tda8uOEDiMDigWysu3xv54M8o6XDlsjR1qt5Y8QLGiKg51fqXFIR2HUTmt9ys0Q=="], "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@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], + "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=="], "connect-history-api-fallback": ["connect-history-api-fallback@1.6.0", "", {}, "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg=="], @@ -936,6 +939,12 @@ "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -962,7 +971,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], @@ -1020,7 +1029,7 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], @@ -1030,8 +1039,6 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], @@ -1070,6 +1077,8 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-empty": ["is-empty@1.2.0", "", {}, "sha512-F2FnH/otLNJv0J6wc73A5Xo7oHLNnqplYqZhUu01tD54DIPvxIRSTSLkrUB/M0nHO4vo1O9PDfN4KoTxCzLh/w=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -1080,12 +1089,16 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "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=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], @@ -1226,7 +1239,7 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "miniflare": ["miniflare@4.20260526.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260526.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA=="], + "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=="], "minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="], @@ -1284,6 +1297,8 @@ "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=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -1428,8 +1443,6 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -1438,17 +1451,11 @@ "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.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="], + "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=="], "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=="], - "rosie-skills": ["rosie-skills@0.6.4", "", { "optionalDependencies": { "rosie-skills-darwin-arm64": "0.6.4", "rosie-skills-freebsd-x64": "0.6.4", "rosie-skills-linux-x64": "0.6.4" }, "bin": { "rosie-skills": "dist/bin.js" } }, "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA=="], - - "rosie-skills-darwin-arm64": ["rosie-skills-darwin-arm64@0.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg=="], - - "rosie-skills-freebsd-x64": ["rosie-skills-freebsd-x64@0.6.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng=="], - - "rosie-skills-linux-x64": ["rosie-skills-linux-x64@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1472,7 +1479,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], "shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="], @@ -1506,7 +1513,7 @@ "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], - "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=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string-width-cjs": ["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=="], @@ -1522,7 +1529,7 @@ "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], - "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], @@ -1552,8 +1559,6 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], @@ -1614,7 +1619,7 @@ "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.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "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-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], + "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-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=="], @@ -1630,11 +1635,11 @@ "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "workerd": ["workerd@1.20260526.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260526.1", "@cloudflare/workerd-darwin-arm64": "1.20260526.1", "@cloudflare/workerd-linux-64": "1.20260526.1", "@cloudflare/workerd-linux-arm64": "1.20260526.1", "@cloudflare/workerd-windows-64": "1.20260526.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-IHzymht98p10JH1zzwdCpbViAqw97HrwKl7+KfZeASFMsYSrIsAULWdPn0LRC5FTUzBpamLNyKCCKxbgXHgRHQ=="], + "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=="], - "wrangler": ["wrangler@4.95.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.20260526.0", "path-to-regexp": "6.3.0", "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", "workerd": "1.20260526.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260526.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-vgXzFVSCdUbeCadgVXvu8fK5tzNm8T9W+7lriyGWZMx0B1+CAdr4d8JTlZszHfgjypRAHmAxb49etZGIRD9pgg=="], + "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=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "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=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1642,15 +1647,17 @@ "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=="], + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "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=="], - "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=="], + "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=="], - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], @@ -1692,8 +1699,6 @@ "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "@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=="], @@ -1722,14 +1727,12 @@ "bun-types/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "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=="], + "copy-anything/is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1760,7 +1763,7 @@ "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1770,12 +1773,8 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "tsx/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=="], - "unenv/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "unified-args/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "unified-engine/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "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=="], @@ -1788,7 +1787,9 @@ "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/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "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=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1796,8 +1797,6 @@ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@npmcli/git/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "@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=="], @@ -1818,7 +1817,11 @@ "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "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=="], + + "cmake-js/yargs/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=="], + + "cmake-js/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -1826,68 +1829,12 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "unified-engine/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified-engine/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "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=="], - "vfile-reporter/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "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=="], "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=="], @@ -1944,9 +1891,9 @@ "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "@npmcli/map-workspaces/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1956,6 +1903,14 @@ "@npmcli/package-json/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "cmake-js/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cmake-js/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "cmake-js/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cmake-js/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "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=="], @@ -2012,6 +1967,12 @@ "@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=="], + + "cmake-js/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cmake-js/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "unified-engine/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/demo/boy/package.json b/demo/boy/package.json index e220c5e82..ee9ee247d 100644 --- a/demo/boy/package.json +++ b/demo/boy/package.json @@ -14,7 +14,7 @@ "devDependencies": { "esbuild": "^0.28.0", "typescript": "^6.0.3", - "vite": "^8.0.14", + "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" } } diff --git a/demo/justfile b/demo/justfile index c456d9be0..c3d009716 100644 --- a/demo/justfile +++ b/demo/justfile @@ -25,10 +25,10 @@ default: export MOQ_WEB_HTTP_LISTEN="[::]:$port" base="http://localhost:$port" - bun run concurrently --kill-others --names srv,bbb,web --prefix-colors auto \ + bun run concurrently --kill-others --names rly,bbb,web --prefix-colors auto \ "just relay" \ - "just wait $base/certificate.sha256 && just pub bbb $base/anon" \ - "just wait $base/certificate.sha256 && just web serve $base/anon" + "just wait $base/certificate.sha256 && just pub bbb $base" \ + "just wait $base/certificate.sha256 && just web serve $base" # Find the first free port at/after `start` (checked on both TCP and UDP). # `lsof` isn't available everywhere (e.g. Git Bash on Windows); without it we diff --git a/demo/relay/justfile b/demo/relay/justfile index 7b6345680..1f73c13a5 100644 --- a/demo/relay/justfile +++ b/demo/relay/justfile @@ -5,16 +5,19 @@ default: cargo run --bin moq-relay -- localhost.toml # Run a cluster of relay servers. -cluster: token +# +# The relays grant anonymous access (see *.toml), so no JWT is needed locally. +# Cluster peers still authenticate to each other via mTLS (the cert/ca recipes). +cluster: bun install bun run concurrently --kill-others --names root,leaf0,leaf1,bbb,tos,web --prefix-colors auto \ "just root" \ "just wait http://localhost:4443/certificate.sha256 && just leaf0" \ "just wait http://localhost:4443/certificate.sha256 && just leaf1" \ - "just wait http://localhost:4444/certificate.sha256 && just pub bbb http://localhost:4444/demo?jwt=$(cat demo-cli.jwt)" \ - "just wait http://localhost:4443/certificate.sha256 && just pub tos http://localhost:4443/demo?jwt=$(cat demo-cli.jwt)" \ - "just wait http://localhost:4445/certificate.sha256 && VITE_RELAY_URL=http://localhost:4445/demo?jwt=$(cat demo-web.jwt) bun --cwd ../web --bun vite --open" + "just wait http://localhost:4444/certificate.sha256 && just pub bbb http://localhost:4444" \ + "just wait http://localhost:4443/certificate.sha256 && just pub tos http://localhost:4443" \ + "just wait http://localhost:4445/certificate.sha256 && VITE_RELAY_URL=http://localhost:4445 bun --cwd ../web --bun vite" # Run a localhost root server, accepting connections from leaf nodes. root: (cert "root") diff --git a/demo/relay/leaf0.toml b/demo/relay/leaf0.toml index c599662df..fb091655d 100644 --- a/demo/relay/leaf0.toml +++ b/demo/relay/leaf0.toml @@ -40,14 +40,15 @@ listen = "[::]:4444" connect = ["https://localhost:4443/"] [auth] -# Allow JWT tokens that are signed by this root key. -# `just key` will populate this file. -key = "root.jwk" - -[auth.public] -# Allow anonymous clients to subscribe and publish to the prefixes below. -subscribe = ["anon", "demo"] -publish = ["anon", "demo/viewer"] +# Local development: anonymous access to everything, so no JWT is needed. +public = "" + +# To exercise JWT + public-prefix auth instead, drop `public` above and restore: +# key = "root.jwk" +# +# [auth.public] +# subscribe = ["anon", "demo"] +# publish = ["anon", "demo/viewer"] [stats] enabled = true diff --git a/demo/relay/leaf1.toml b/demo/relay/leaf1.toml index a90bc2b55..c250ea9b2 100644 --- a/demo/relay/leaf1.toml +++ b/demo/relay/leaf1.toml @@ -40,14 +40,15 @@ listen = "[::]:4445" connect = ["https://localhost:4443/", "https://localhost:4444/"] [auth] -# Allow JWT tokens that are signed by this root key. -# `just key` will populate this file. -key = "root.jwk" - -[auth.public] -# Allow anonymous clients to subscribe and publish to the prefixes below. -subscribe = ["anon", "demo"] -publish = ["anon", "demo/viewer"] +# Local development: anonymous access to everything, so no JWT is needed. +public = "" + +# To exercise JWT + public-prefix auth instead, drop `public` above and restore: +# key = "root.jwk" +# +# [auth.public] +# subscribe = ["anon", "demo"] +# publish = ["anon", "demo/viewer"] [stats] enabled = true diff --git a/demo/relay/root.toml b/demo/relay/root.toml index 187fe0102..05e6c23af 100644 --- a/demo/relay/root.toml +++ b/demo/relay/root.toml @@ -26,10 +26,12 @@ tls.root = ["ca.pem"] listen = "[::]:4443" [auth] -# Allow JWT tokens that are signed by this root key. -key = "root.jwk" +# Local development: anonymous access to everything, so no JWT is needed. +public = "" -[auth.public] -# Allow anonymous clients to subscribe and publish to the prefixes below. -subscribe = ["anon", "demo"] -publish = ["anon", "demo/viewer"] +# To exercise JWT + public-prefix auth instead, drop `public` above and restore: +# key = "root.jwk" +# +# [auth.public] +# subscribe = ["anon", "demo"] +# publish = ["anon", "demo/viewer"] diff --git a/demo/sub/justfile b/demo/sub/justfile index 66e50afd4..e7ec69ed8 100644 --- a/demo/sub/justfile +++ b/demo/sub/justfile @@ -4,6 +4,10 @@ set fallback gst name url='http://localhost:4443' *args: cargo build -p moq-gst + # moqsrc exposes one pad per rendition (video_0, audio_0, ...). Link the video pad + # by name so a bare `! decodebin3` can't latch onto the audio pad and leave the + # video sink empty; route audio to its own sink so it doesn't stall the session. GST_PLUGIN_PATH_1_0="${PWD}/../../target/debug${GST_PLUGIN_PATH_1_0:+:$GST_PLUGIN_PATH_1_0}" \ - gst-launch-1.0 -v -e moqsrc url="{{ url }}" broadcast="{{ name }}" {{ args }} \ - ! decodebin3 ! videoconvert ! autovideosink + gst-launch-1.0 -v -e moqsrc name=s url="{{ url }}" broadcast="{{ name }}" {{ args }} \ + s.video_0 ! queue ! decodebin3 ! videoconvert ! autovideosink \ + s.audio_0 ! queue ! decodebin3 ! audioconvert ! autoaudiosink diff --git a/demo/web/README.md b/demo/web/README.md index 7ce9e1ac8..9dba17090 100644 --- a/demo/web/README.md +++ b/demo/web/README.md @@ -15,6 +15,12 @@ The principles are the same but the implementation is exponentially simpler give These are demos, duh. We're using Vite but other bundlers should work too. +Run `just web` (or `bun --bun vite` from this directory) and open the pages: + +- `index.html` - Watch inspector: one tile per live broadcast discovered under a prefix, click to make a tile active (audio + a live stats panel for video/audio/network and a custom `meta.json` metadata track). +- `publish.html` - Publish from a camera/screen/file, plus an editor for the custom `meta.json` metadata track. +- `stats.html` - Relay stats dashboard: auto-discovers every node publishing `.stats` and aggregates external vs. cluster traffic. Needs `[stats] enabled = true` on the relay (the demo configs already set it). + # License Licensed under either: diff --git a/demo/web/justfile b/demo/web/justfile index 840dd559b..6d3cd3a7a 100644 --- a/demo/web/justfile +++ b/demo/web/justfile @@ -5,5 +5,7 @@ default: just serve # Run the web server targeting the specified relay. +# The vite config opens the watch, publish, and stats demos in separate tabs; +# set MOQ_NO_OPEN=1 to skip opening a browser. serve url='http://localhost:4443': - VITE_RELAY_URL="{{ url }}" bun --bun vite --open + VITE_RELAY_URL="{{ url }}" bun --bun vite diff --git a/demo/web/open-tabs.ts b/demo/web/open-tabs.ts new file mode 100644 index 000000000..49639112b --- /dev/null +++ b/demo/web/open-tabs.ts @@ -0,0 +1,33 @@ +import open from "open"; +import type { Plugin } from "vite"; + +/** + * Dev-only plugin that opens several pages in the browser once the dev server is + * listening. Vite's `--open` only opens one URL; this opens a tab per path so + * `just web` brings up the watch, publish, and stats demos side by side. + * + * Pass `--open` to vite to also let it open the default page (don't; this + * replaces it). Set MOQ_NO_OPEN=1 to skip opening entirely. + */ +export function openTabs(paths: string[]): Plugin { + return { + name: "moq-open-tabs", + apply: "serve", + configureServer(server) { + if (process.env.MOQ_NO_OPEN) return; + + server.httpServer?.once("listening", () => { + const address = server.httpServer?.address(); + const port = typeof address === "object" && address ? address.port : server.config.server.port; + const protocol = server.config.server.https ? "https" : "http"; + const base = `${protocol}://localhost:${port}`; + + for (const path of paths) { + open(`${base}/${path}`).catch(() => { + // Opening a browser is best-effort (e.g. headless CI); ignore failures. + }); + } + }); + }, + }; +} diff --git a/demo/web/package.json b/demo/web/package.json index 0e8bae2af..1725edeb2 100644 --- a/demo/web/package.json +++ b/demo/web/package.json @@ -7,7 +7,7 @@ "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", "scripts": { - "dev": "vite --open", + "dev": "vite", "build": "vite build", "check": "tsc --noEmit" }, @@ -18,15 +18,16 @@ }, "//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.16", + "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.0", "esbuild": "^0.28.0", "highlight.js": "^11.11.1", + "open": "^10.2.0", "solid-element": "^1.9.1", "solid-js": "^1.9.13", "tailwindcss": "^4.1.13", "typescript": "^6.0.3", - "vite": "^8.0.14", + "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" } } diff --git a/demo/web/src/discover.ts b/demo/web/src/discover.ts deleted file mode 100644 index efde1122a..000000000 --- a/demo/web/src/discover.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Signals } from "@moq/hang"; -import type MoqWatch from "@moq/watch/element"; - -/** - * Wraps a element and live discovers new broadcasts available at the given URL. - * Displays clickable broadcast names above the player. - */ -export default class MoqDiscover extends HTMLElement { - #suggestions: HTMLDivElement; - #signals = new Signals.Effect(); - - constructor() { - super(); - - // Create suggestions container - this.#suggestions = document.createElement("div"); - this.#suggestions.style.cssText = "margin-bottom: 0.5rem; font-size: 0.85rem;"; - } - - async connectedCallback() { - this.style.cssText = "display: block; margin: 1rem 0;"; - - // Discover the inner moq-watch element. - await customElements.whenDefined("moq-watch"); - const watch = this.querySelector("moq-watch") as MoqWatch | null; - if (!watch) return; - - // Insert the suggestions above the existing children. - this.prepend(this.#suggestions); - - // Reactively render suggestions when broadcasts or selected name changes. - this.#signals.run((effect) => { - const broadcasts = effect.get(watch.connection.announced); - const selected = effect.get(watch.broadcast.input.name).toString(); - - this.#clearSuggestions(); - - if (broadcasts.size === 0) return; - - const label = document.createElement("span"); - label.textContent = "Available: "; - label.style.color = "#666"; - this.#suggestions.appendChild(label); - - for (const name of broadcasts) { - const isSelected = name === selected; - const tag = document.createElement("button"); - tag.type = "button"; - tag.textContent = name; - - const defaultBg = isSelected ? "#2d4a2d" : "#1a2e1a"; - const defaultBorder = isSelected ? "#4ade80" : "#2d4a2d"; - - tag.style.cssText = ` - background: ${defaultBg}; color: #4ade80; border: 1px solid ${defaultBorder}; - padding: 0.2rem 0.5rem; margin: 0 0.25rem; border-radius: 4px; - font-size: 0.8rem; font-family: monospace; cursor: pointer; - font-weight: ${isSelected ? "bold" : "normal"}; - transition: background 0.15s, border-color 0.15s; - `; - if (!isSelected) { - tag.addEventListener("mouseenter", () => { - tag.style.background = "#2d4a2d"; - tag.style.borderColor = "#4ade80"; - }); - tag.addEventListener("mouseleave", () => { - tag.style.background = defaultBg; - tag.style.borderColor = defaultBorder; - }); - } - tag.addEventListener("click", () => { - watch.name = name; - }); - this.#suggestions.appendChild(tag); - } - }); - } - - disconnectedCallback() { - this.#signals.close(); - } - - #clearSuggestions() { - while (this.#suggestions.firstChild) { - this.#suggestions.removeChild(this.#suggestions.firstChild); - } - } -} - -customElements.define("moq-discover", MoqDiscover); diff --git a/demo/web/src/index.css b/demo/web/src/index.css index a80887417..ebcf7a11d 100644 --- a/demo/web/src/index.css +++ b/demo/web/src/index.css @@ -8,6 +8,13 @@ button { } body { - /* We're using Tailwind for the prose styles. */ - @apply prose bg-neutral-950 text-white dark:prose-invert lg:prose-xl mx-auto; + @apply bg-neutral-950 text-white; +} + +/* Documentation-style content (the publish page, footers) opts into prose + typography. The app-style layouts (watch inspector, stats) use utility + classes instead, so prose's centered max-width doesn't fight their + multi-column layouts. */ +.prose-doc { + @apply prose dark:prose-invert lg:prose-xl mx-auto; } diff --git a/demo/web/src/index.html b/demo/web/src/index.html index 299b48d59..fe0053f45 100644 --- a/demo/web/src/index.html +++ b/demo/web/src/index.html @@ -4,186 +4,149 @@ - MoQ Demo + MoQ Demo (Watch) - - - - - -
- - - - - - - - - - - - - + + + + +

MoQ Watch

+

+ One tile per live broadcast announced under the prefix. Click a tile to make it active; only the + active tile plays sound, and the panel on the right shows its live stats. Everything is discovered + over a single connection. +

+ +
+ +
+
+
+ Searching for live broadcasts… +
+
+ + +
- - -

Other demos:

- - -

Tips:

-

- You can find the source code for this demo in dev/web/src/index.html. - Yes I know it's confusing when a command automatically opens a browser window. -

-

- This demo uses - http so it's extra not secure. - It works by insecurely fetching the certificate hash and telling WebTransport to trust it. - If you're going to run this code in production, you'll need a valid certificate (ex. LetsEncrypt) and use - https. -

-
-

- You can instanciate the player via the provided <moq-watch> Web Component. - Either modify HTML attributes like <moq-watch paused> or use the - Javascript API. - The Javascript API is still evolving, so I recommend the Web Component for now. -

-

- You can provide your own canvas element and use CSS to modify it. - Unfortunately, you can't use the HTML width/height attributes because of how OffscreenCanvas - works. - For example: -

<moq-watch url="http://localhost:4443/" name="bbb.hang">
-	<!-- Optionally provide a custom canvas element that we can style as needed -->
-	<canvas style="max-width: 100%; height: auto; border-radius: 4px;"></canvas>
-</moq-watch>
-

-

- The player sits in a resizable container; grab the right edge and drag. - <moq-watch> monitors its own rendered size (scaled by - devicePixelRatio) and requests the smallest rendition that still fills the canvas. - The default bunny broadcast only has one rendition, so nothing will change. - Instead, open the publish demo, which uses the simulcast attribute to publish both `video/hd` and `video/sd`, and then watch your own broadcast. - Keep the Quality dropdown on Auto and resize away; the exact switch point depends on your display's - devicePixelRatio. - Switching is seamless: the player keeps decoding the old rendition until the new one catches up. -

-

- Don't want video? Don't provide a canvas! - It won't be downloaded, decoded, or rendered. - This includes when the video is paused, minimized, not in the DOM, or scrolled out of view. - wowee the bandwidth savings! -

-

- Use visible="200px" to pre-warm video before it scrolls into view (still suspended while the tab is hidden), or visible="always" to download it regardless of scroll position or tab visibility. -

-

- Audio may start muted because the browser can require user interaction before autoplaying. - You can unmute it by removing the muted property or calling watch.audio.muted.set(false) via the Javascript API. - And of course, nothing is downloaded while it's muted. -

-
-

- The Javascript API is far more powerful and you can access properties directly: - -

const watch = document.getElementById("watch");
-watch.audio.muted.set(true);
-
-

- -

- All of the properties are reactive using a hand-rolled signals library: `@moq/signals`. - You could use it... or you can use the provided `react` and `solid` helpers: - -


-import { Watch } from "@moq/hang";
-import solid from "@moq/signals/solid";
-
-function Volume(hang: Watch) {
-	// Switch to `react` if you're using React, duh.
-	const volume = solid(hang.volume);
-
-	// Return a div that displays the volume.
-	return <div>
-		Volume: {volume()}
-	</div>
-}
-		
-

-

- Using something more niche? There's also a subscribe() method to - trigger a callback on change. - -


-const cleanup = hang.volume.subscribe((volume) => {
-	document.getElementById("volume-value").textContent = `${volume * 100}%`;
-});
-
-// Cleanup the subscription when no longer needed.
-cleanup();
-		
-

-
-

- The connection and broadcast are automatically reloaded. - Try running multiple terminals and kill the broadcast to see what happens. - -

# Run the relay and web server in another terminal or the background.
-just relay &
-just web &
-
-just pub bbb
-# Kill it with ctrl+C
-
-# Republish the same broadcast, the player will reconnect.
-just pub bbb
-		
-

-

- If the Big Bunny is making you sick, you can use other inferior test videos or the publish demo. - For example, - Try running just pub tos in a new terminal and then watch robots bang. - This command uses ffmpeg to produce a fragmented MP4 file piped over stdout and then sent over the network. - Yeah it's pretty gross. -

-

- If you want to do things more efficiently, you can use the - GStreamer plugin. - It's pretty crude and doesn't handle all pipeline events; contributions welcome! -

+ + +
+

How it works:

+

+ Each tile is a <moq-watch-ui> wrapping a + <moq-watch> Web Component. + A single connection discovers broadcasts via MoQ announcements + (connection.announced(prefix)) and we reconcile one + tile per live .hang broadcast, adding and removing them as publishers come and go. + No F5 needed; restart a publisher and its tile reconnects on its own. +

+

+ The right panel reads everything reactively off the active tile's element: +

const watch = document.querySelector("moq-watch");
+watch.broadcast.output.catalog.subscribe((catalog) => { /* video/audio config */ });
+watch.backend.video.output.stats.subscribe((stats) => { /* decoded frames, bytes */ });
+

+

+ The metadata panel subscribes to a custom meta.json track carried within the + broadcast (see the publish demo), advertised in the catalog's + metadata list and reconstructed from a snapshot plus merge-patch deltas via + @moq/json. +

+

+ Don't want sound from a tile? It only downloads audio while active. Don't want video at all? + Remove the canvas and nothing is downloaded, decoded, or rendered. +

+

+ This demo uses http so it's extra not secure: it insecurely fetches the certificate + hash and tells WebTransport to trust it. In production you'll need a real certificate (ex. + LetsEncrypt) and https. +

+ +

Other demos:

+ +
diff --git a/demo/web/src/index.ts b/demo/web/src/index.ts index a90f3eabf..b25851a66 100644 --- a/demo/web/src/index.ts +++ b/demo/web/src/index.ts @@ -1,18 +1,535 @@ +/** + * MoQ watch inspector. + * + * We discover every broadcast announced under a prefix and render one tile per + * live broadcast on the left: a `` (player chrome) wrapping a + * `` element. The right column shows live stats (catalog, decode, + * network, metadata) for the *active* tile only, read straight off that tile's + * `` `broadcast`/`backend` signals. + * + * Audio policy: only the active tile plays sound. Clicking a tile makes it + * active (and is the user gesture that lets its audio start); every other tile + * is muted. + * + * The per-stream metadata rides on a separate `meta.json` track within the active + * broadcast (advertised in the catalog's `metadata` list); we subscribe to it off + * the tile's `broadcast.output.active` consumer and decode it with @moq/json. + */ + import "./highlight"; -import "@moq/watch/ui"; -import MoqWatch from "@moq/watch/element"; +import "@moq/watch/element"; // defines +import "@moq/watch/ui"; // defines +import { Json, Net, Signals } from "@moq/watch"; +import type MoqWatch from "@moq/watch/element"; import MoqWatchSupport from "@moq/watch/support/element"; -import MoqDiscover from "./discover"; +import { bufferBars, formatBitrate, formatFps, graph, renderRows } from "./viz"; + +/** Re-exported so bundlers keep the `` element registration. */ +export { MoqWatchSupport }; + +// Injected by Vite (see justfile). Defaults to the local relay. +const RELAY_URL = import.meta.env.VITE_RELAY_URL ?? "http://localhost:4443"; + +const $ = (id: string): T => { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el as T; +}; + +// Build a branded path from a user-typed prefix, tolerating a trailing slash +// (we show "demo/" in the UI but the path is "demo"). +const prefixPath = (raw: string): Net.Path.Valid => Net.Path.from(raw.trim().replace(/\/+$/, "")); + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +// Empty prefix discovers every broadcast on the relay. +const prefixInput = new Signals.Signal(""); + +// Active broadcasts announced under the prefix (full paths), sorted. +const broadcasts = new Signals.Signal([]); + +// The active tile: the only one that plays audio. undefined => all muted. +const active = new Signals.Signal(undefined); + +// The active tile's element, or undefined when nothing is active. +// The right-hand stats panel reads everything off this. +const activeWatch = new Signals.Signal(undefined); + +// The decoded value of the active broadcast's `meta.json` track, or undefined when +// the broadcast advertises none. +const metaSignal = new Signals.Signal(undefined); + +// The relay URL, editable at runtime. Both the discovery connection and every +// tile's follow it reactively. +const relayUrl = new Signals.Signal(new URL(RELAY_URL)); + +// Discovery connection (the tiles each open their own connection internally). +const connection = new Net.Connection.Reload({ url: relayUrl, enabled: true }); + +// --------------------------------------------------------------------------- +// Per-broadcast tile (a in the left column) +// --------------------------------------------------------------------------- + +interface WatchTile { + readonly name: string; + readonly el: HTMLElement; + readonly watch: MoqWatch; + close(): void; +} + +function createTile(name: string): WatchTile { + const el = document.createElement("div"); + el.className = + "rounded-lg overflow-hidden border border-neutral-800 bg-neutral-900 cursor-pointer transition-colors"; + + const label = document.createElement("div"); + label.className = + "flex items-center gap-2 px-3 py-1.5 text-xs font-mono text-neutral-300 border-b border-neutral-800"; + const labelText = document.createElement("span"); + labelText.className = "truncate"; + labelText.textContent = (Net.Path.stripPrefix(prefixPath(prefixInput.peek()), Net.Path.from(name)) ?? + name) as string; + // Speaker badge marking the tile whose audio is playing (active + has audio). + const audioBadge = document.createElement("span"); + audioBadge.className = "ml-auto shrink-0"; + audioBadge.textContent = "🔊"; + audioBadge.title = "audio active"; + audioBadge.hidden = true; + label.append(labelText, audioBadge); + + // Each tile is a (player chrome: play/pause, volume, + // fullscreen) wrapping a bare that renders into its + // child. We still drive audio on the inner and read its stats off + // `broadcast`/`backend`, so the shared inspector panel reflects the active tile. + const watch = document.createElement("moq-watch") as MoqWatch; + watch.name = name; + watch.reload = true; // wait for (re)announcement; survives publisher restarts + watch.muted = true; // unmuted only while active (see below) + // Default to a fixed 100ms jitter buffer (instead of adaptive "real-time") so + // the latency visualization has something to show. Drag it in the panel. + watch.setAttribute("latency", "100"); + const canvas = document.createElement("canvas"); + canvas.style.cssText = "width: 100%; height: auto;"; + watch.appendChild(canvas); + + const player = document.createElement("moq-watch-ui"); + player.appendChild(watch); + el.append(label, player); + + const effects = new Signals.Effect(); + + // Clicking anywhere in the tile makes it the active audio source. The click + // doubles as the user gesture browsers require before audio can start. + effects.event(el, "pointerdown", () => active.set(name)); + + // Follow the editable relay URL in its own effect. Keeping this separate from + // the active-state effect below is important: `watch.url =` reassigns a fresh + // URL into the connection, which reconnects and flashes the canvas black. We + // only want that when the URL actually changes, not on every active switch. + effects.run((effect) => { + watch.url = effect.get(relayUrl); + }); + + // Active state: only toggle audio + the active styling, so switching tiles + // keeps the video playing. + effects.run((effect) => { + const isActive = effect.get(active) === name; + el.classList.toggle("border-emerald-500", isActive); + el.classList.toggle("border-neutral-800", !isActive); + watch.muted = !isActive; + // Show the speaker badge on the active tile, but only when it actually has + // an audio track to play. + const hasAudio = !!effect.get(watch.broadcast.output.catalog)?.audio; + audioBadge.hidden = !(isActive && hasAudio); + }); + + return { + name, + el, + watch, + close() { + effects.close(); + el.remove(); // disconnects -> stops its connection + }, + }; +} + +// --------------------------------------------------------------------------- +// Broadcast discovery +// --------------------------------------------------------------------------- +// +// Subscribe to announcements under the prefix and keep a live set of active +// broadcasts. `announced.next()` drains a queue, so we track membership +// ourselves: active=true adds the path, active=false removes it. +const discovery = new Signals.Effect(); +discovery.run((effect) => { + const conn = effect.get(connection.established); + broadcasts.set([]); + if (!conn) return; + + const announced = conn.announced(prefixPath(effect.get(prefixInput))); + effect.cleanup(() => announced.close()); + + const live = new Set(); + effect.spawn(async () => { + for (;;) { + const entry = await Promise.race([effect.cancel, announced.next()]); + if (!entry) break; + // Only `.hang` broadcasts are watchable streams; this skips the relay's + // `.stats` broadcast (see the stats dashboard demo for that one). + if (!entry.path.endsWith(".hang")) continue; + if (entry.active) live.add(entry.path); + else live.delete(entry.path); + broadcasts.set([...live].sort()); + } + }); +}); + +// --------------------------------------------------------------------------- +// Tile lifecycle: reconcile against discovery +// --------------------------------------------------------------------------- +// +// A persistent map outside the effect so re-runs reconcile (add new, close gone) +// rather than tearing every tile down. +const tiles = new Map(); +const playersContainer = $("players"); + +// Recompute the active tile's element from the current selection + tile map. +// Called from both the reconcile effect (tiles changed) and the selection effect +// (active changed) so `activeWatch` is never left pointing at a stale or +// not-yet-created tile. +function syncActiveWatch(): void { + const name = active.peek(); + activeWatch.set(name ? tiles.get(name)?.watch : undefined); +} + +const tilesEffect = new Signals.Effect(); +tilesEffect.run((effect) => { + const list = effect.get(broadcasts); + const live = new Set(list); + + for (const [name, t] of tiles) { + if (!live.has(name)) { + t.close(); + tiles.delete(name); + } + } + for (const name of list) { + if (!tiles.has(name)) tiles.set(name, createTile(name)); + } + // Keep DOM order matching the sorted list (append moves existing nodes). + for (const name of list) { + const t = tiles.get(name); + if (t) playersContainer.append(t.el); + } + + $("players-empty").hidden = list.length > 0; + syncActiveWatch(); +}); + +// --------------------------------------------------------------------------- +// Reactive UI +// --------------------------------------------------------------------------- + +const ui = new Signals.Effect(); + +// Relay URL is editable: on commit, reconnect discovery + every tile to it. +const relayEl = $("relay-url"); +relayEl.value = RELAY_URL; +relayEl.addEventListener("change", () => { + try { + relayUrl.set(new URL(relayEl.value.trim())); + } catch { + // Revert invalid input to the last good URL. + relayEl.value = relayUrl.peek()?.toString() ?? RELAY_URL; + } +}); + +const prefixEl = $("prefix"); +prefixEl.value = prefixInput.peek(); +prefixEl.addEventListener("input", () => prefixInput.set(prefixEl.value)); + +// Keep the active tile valid: auto-pick the first broadcast and switch away from +// one that disappears, but never steal focus once the user has chosen. +ui.run((effect) => { + const list = effect.get(broadcasts); + const cur = active.peek(); + if (cur && list.includes(cur)) return; + active.set(list[0]); +}); + +// Point `activeWatch` at the selected tile's element whenever the selection +// changes (tile creation is handled by `tilesEffect`, also via syncActiveWatch). +ui.run((effect) => { + effect.get(active); + syncActiveWatch(); +}); + +// Connection pill: Connected / Connecting / Disconnected. +ui.run((effect) => { + const status = effect.get(connection.status); // connecting | connected | disconnected + const label = status.charAt(0).toUpperCase() + status.slice(1); + setPill( + "conn-status", + "conn-text", + label, + status === "connected" ? "ok" : status === "connecting" ? "wait" : "bad", + ); +}); + +// Broadcast pill: Online when the active broadcast is live, else Loading/Offline. +ui.run((effect) => { + const watch = effect.get(activeWatch); + const stream = watch ? effect.get(watch.broadcast.output.status) : "offline"; // offline | loading | live + if (stream === "live") setPill("bcast-status", "bcast-text", "Online", "ok"); + else if (watch && stream === "loading") setPill("bcast-status", "bcast-text", "Loading", "wait"); + else setPill("bcast-status", "bcast-text", "Offline", "bad"); +}); + +// Video section: only shown when the active catalog has a video section. Inlines +// the video track config from the catalog plus live decode stats. +ui.run((effect) => { + const watch = effect.get(activeWatch); + const catalog = watch ? effect.get(watch.broadcast.output.catalog) : undefined; + const video = catalog?.video; + const section = $("video-section"); + if (!watch || !video) { + section.hidden = true; + return; + } + section.hidden = false; + + const stalled = effect.get(watch.backend.video.output.stalled); + const live = effect.get(watch.broadcast.output.status) === "live"; + const r = Object.values(video.renditions)[0]; + + const resolution = + r?.codedWidth && r?.codedHeight + ? `${r.codedWidth}×${r.codedHeight}` + : video.display + ? `${video.display.width}×${video.display.height}` + : undefined; + + renderRows($("video-info"), [ + ["codec", r?.codec], + ["resolution", resolution], + ["framerate", r?.framerate ? `${r.framerate} fps` : undefined], + ["bitrate", r?.bitrate ? `${Math.round(r.bitrate / 1000)} kbps` : undefined], + // A stall is mid-stream starvation, not "offline" - only surface it when live. + ["stalled", live && stalled ? "⚠️ recovering" : undefined], + ]); +}); + +// Audio section: only shown when the active catalog has an audio section. +ui.run((effect) => { + const watch = effect.get(activeWatch); + const catalog = watch ? effect.get(watch.broadcast.output.catalog) : undefined; + const audio = catalog?.audio; + const section = $("audio-section"); + if (!watch || !audio) { + section.hidden = true; + return; + } + section.hidden = false; + + const a = Object.values(audio.renditions)[0]; + renderRows($("audio-info"), [ + ["codec", a?.codec], + ["sample rate", a?.sampleRate ? `${a.sampleRate} Hz` : undefined], + ["channels", a?.numberOfChannels ? String(a.numberOfChannels) : undefined], + ["bitrate", a?.bitrate ? `${Math.round(a.bitrate / 1000)} kbps` : undefined], + ]); +}); + +// Network section: only shown while connected to the relay with an active tile. +ui.run((effect) => { + const connected = effect.get(connection.status) === "connected"; + const watch = effect.get(activeWatch); + const section = $("network-section"); + if (!connected || !watch) { + section.hidden = true; + return; + } + section.hidden = false; + + const video = effect.get(watch.backend.video.output.stats); + const audio = effect.get(watch.backend.audio.output.stats); + const bytes = (video?.bytesReceived ?? 0) + (audio?.bytesReceived ?? 0); + renderRows($("network-info"), [["bytes received", bytes > 0 ? formatBytes(bytes) : undefined]]); +}); + +// Raw catalog (collapsible) - only rendered once the active catalog arrives. +ui.run((effect) => { + const watch = effect.get(activeWatch); + const catalog = watch ? effect.get(watch.broadcast.output.catalog) : undefined; + const section = $("catalog-raw-section"); + if (!catalog) { + section.hidden = true; + return; + } + section.hidden = false; + $("catalog-raw").textContent = JSON.stringify(catalog, null, 2); +}); + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- +// +// The publish demo serves its metadata as a separate `meta.json` track, advertised +// in the catalog's `metadata` list. We read the active broadcast off +// `broadcast.output.active`, subscribe to that track, and decode the JSON value, +// re-subscribing whenever the broadcast (or the advertised track) changes. +// Memoize the advertised track name so the subscription below only re-runs when it +// (or the active broadcast) changes, not on every catalog frame (e.g. a live +// encoder-setting tweak rewrites the catalog). +const metaTrackName = ui.computed((effect) => { + const watch = effect.get(activeWatch); + if (!watch) return undefined; + const catalog = effect.get(watch.broadcast.output.catalog) as { metadata?: string[] } | undefined; + return catalog?.metadata?.[0]; +}); + +ui.run((effect) => { + const watch = effect.get(activeWatch); + const broadcast = watch ? effect.get(watch.broadcast.output.active) : undefined; + const trackName = effect.get(metaTrackName); + if (!broadcast || !trackName) { + metaSignal.set(undefined); + return; + } + + const track = broadcast.track(trackName).subscribe(); + effect.cleanup(() => track.close()); + const consumer = new Json.Consumer(track); + + effect.spawn(async () => { + try { + for (;;) { + const value = await Promise.race([effect.cancel, consumer.next()]); + if (value === undefined) break; + metaSignal.set(value); + } + } catch (err) { + console.warn("error reading metadata", err); + } finally { + metaSignal.set(undefined); + } + }); +}); + +// Metadata view - only shown when the active broadcast is live AND has actually +// received a frame (no placeholder text while offline). +ui.run((effect) => { + const meta = effect.get(metaSignal); + const watch = effect.get(activeWatch); + const live = watch ? effect.get(watch.broadcast.output.status) === "live" : false; + const section = $("metadata-section"); + const pre = $("metadata"); + if (live && meta !== undefined) { + section.hidden = false; + pre.textContent = JSON.stringify(meta, null, 2); + } else { + section.hidden = true; + pre.textContent = ""; + } +}); + +// --------------------------------------------------------------------------- +// Live graphs (bitrate / frame rate / RTT) + buffer visualization +// --------------------------------------------------------------------------- +// +// These are stateful DOM elements, so we build them once and feed them from a +// single timer that samples the *active* tile, rather than rebuilding per render. + +const viz = new Signals.Effect(); + +// Video bitrate is video-only; the Network "Throughput" graph is video + audio. +const bitrateGraph = graph(viz, "Bitrate", { color: "#a855f7", format: formatBitrate }); +const fpsGraph = graph(viz, "Frame rate", { color: "#facc15", format: formatFps }); +$("video-graphs").append(bitrateGraph.el, fpsGraph.el); + +const throughputGraph = graph(viz, "Throughput", { color: "#34d399", format: formatBitrate }); +const rttGraph = graph(viz, "Round trip", { color: "#38bdf8", format: (v) => `${Math.round(v)} ms` }); +$("network-graphs").append(throughputGraph.el, rttGraph.el); + +const allGraphs = [bitrateGraph, fpsGraph, throughputGraph, rttGraph]; + +// Sample the active tile's byte/frame counters and push per-second rates. +let prevWatch: MoqWatch | undefined; +let prev = { frames: 0, videoBytes: 0, totalBytes: 0, when: performance.now() }; +viz.interval(() => { + const watch = activeWatch.peek(); + const now = performance.now(); + + // Reset baselines when switching tiles (or when idle) so the first sample + // isn't a huge spike from the counter difference. + if (watch !== prevWatch || !watch) { + prevWatch = watch; + prev = { frames: 0, videoBytes: 0, totalBytes: 0, when: now }; + for (const g of allGraphs) g.push(undefined); + return; + } + + const v = watch.backend.video.output.stats.peek(); + const a = watch.backend.audio.output.stats.peek(); + const videoBytes = v?.bytesReceived ?? 0; + const totalBytes = videoBytes + (a?.bytesReceived ?? 0); + const frames = v?.frameCount ?? 0; + const elapsed = now - prev.when; + + const perSec = (delta: number) => (delta >= 0 ? (delta * 1000) / elapsed : undefined); + let bitrate: number | undefined; + let throughput: number | undefined; + let fps: number | undefined; + if (elapsed > 0 && prev.totalBytes > 0) { + bitrate = perSec((videoBytes - prev.videoBytes) * 8); + throughput = perSec((totalBytes - prev.totalBytes) * 8); + fps = perSec(frames - prev.frames); + } + bitrateGraph.push(bitrate); + fpsGraph.push(fps); + throughputGraph.push(throughput); + + const conn = watch.connection.established.peek(); + const rtt = conn?.rtt?.peek() as unknown as number | undefined; + rttGraph.push(rtt && rtt > 0 ? rtt : undefined); + + prev = { frames, videoBytes, totalBytes, when: now }; +}, 250); -export { MoqDiscover, MoqWatch, MoqWatchSupport }; +// Rebuild the buffer visualization whenever the active tile changes; it binds to +// one element and runs its own animation loop until its child effect closes. +ui.run((effect) => { + const watch = effect.get(activeWatch); + const live = watch ? effect.get(watch.broadcast.output.status) === "live" : false; + const section = $("buffer-section"); + const host = $("buffer-viz"); + host.replaceChildren(); + if (!watch || !live) { + section.hidden = true; + return; + } + section.hidden = false; + const child = new Signals.Effect(); + effect.cleanup(() => child.close()); + host.append(bufferBars(child, watch)); +}); -const watch = document.querySelector("moq-watch") as MoqWatch | undefined; -if (!watch) throw new Error("unable to find element"); +// --------------------------------------------------------------------------- +// Small render helpers +// --------------------------------------------------------------------------- -// If query params are provided, use them. -const urlParams = new URLSearchParams(window.location.search); -const name = urlParams.get("broadcast") ?? urlParams.get("name"); -const url = urlParams.get("url"); +function setPill(statusId: string, textId: string, label: string, state: "ok" | "wait" | "bad"): void { + $(textId).textContent = label; + const dot = $(statusId).querySelector(".dot") as HTMLElement; + const color = state === "ok" ? "bg-emerald-500" : state === "wait" ? "bg-amber-400" : "bg-red-500"; + dot.className = `dot w-2 h-2 rounded-full ${color}`; +} -if (url) watch.url = url; -if (name) watch.name = name; +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/demo/web/src/manual.html b/demo/web/src/manual.html deleted file mode 100644 index 1152ee8d6..000000000 --- a/demo/web/src/manual.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - MoQ Demo (Manual Catalog) - - - - - - - - - - - - - - - -
-

Catalog JSON

-

- Paste a Catalog.Root blob and click Apply. - The element won't fetch a catalog track in manual mode — media tracks are - subscribed off whatever you provide here. -

-

- To grab a real one, open the WebCodecs demo, - let it play, then run JSON.stringify(document.getElementById("watch").catalog) - in the dev console and paste the result here. -

- - - -
- - -
-
- - - - -

Other demos:

- - - - - - diff --git a/demo/web/src/manual.ts b/demo/web/src/manual.ts deleted file mode 100644 index 1d2624d96..000000000 --- a/demo/web/src/manual.ts +++ /dev/null @@ -1,51 +0,0 @@ -import "./highlight"; -import "@moq/watch/ui"; -import * as Catalog from "@moq/hang/catalog"; -import MoqWatch from "@moq/watch/element"; -import MoqWatchSupport from "@moq/watch/support/element"; - -export { MoqWatch, MoqWatchSupport }; - -const watch = document.querySelector("moq-watch") as MoqWatch | null; -if (!watch) throw new Error("missing element"); - -const input = document.getElementById("catalog-input") as HTMLTextAreaElement; -const apply = document.getElementById("apply") as HTMLButtonElement; -const status = document.getElementById("status") as HTMLSpanElement; - -const urlParams = new URLSearchParams(window.location.search); -const name = urlParams.get("broadcast") ?? urlParams.get("name"); -const url = urlParams.get("url"); -if (url) watch.url = url; -if (name) watch.name = name; - -function setStatus(msg: string, ok = true) { - status.textContent = msg; - status.style.color = ok ? "" : "tomato"; -} - -apply.addEventListener("click", () => { - const text = input.value.trim(); - if (!text) { - watch.catalog = undefined; - setStatus("cleared"); - return; - } - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (err) { - setStatus(`parse error: ${(err as Error).message}`, false); - return; - } - - const result = Catalog.RootSchema.safeParse(parsed); - if (!result.success) { - setStatus(`invalid catalog: ${result.error.message}`, false); - return; - } - - watch.catalogFormat = "manual"; - watch.catalog = result.data; - setStatus("applied"); -}); diff --git a/demo/web/src/mse.html b/demo/web/src/mse.html deleted file mode 100644 index 26edf54ee..000000000 --- a/demo/web/src/mse.html +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - MoQ Demo (MSE) - - - - - - - - - - - - - - - - - - - - -

Other demos:

- - -

Tips:

-

- This demo uses MSE (Media - Source Extensions) - via a <video> element instead of WebCodecs via <canvas>. - MSE has broader device support but higher latency. -

-

- To use MSE, provide a <video> element instead of a <canvas> element: -

<moq-watch url="https://cdn.moq.dev/anon" name="bbb.hang">
-	<video style="max-width: 100%; height: auto;" autoplay muted></video>
-</moq-watch>
-

- - - - - diff --git a/demo/web/src/publish.html b/demo/web/src/publish.html index c1f057476..6d738a63e 100644 --- a/demo/web/src/publish.html +++ b/demo/web/src/publish.html @@ -4,112 +4,267 @@ - MoQ Demo + MoQ Demo (Publish) - - - + + + - - - - +

MoQ Publish

+
+ +
- - - - - - - -

Other demos:

- - -

Tips:

-

- This page creates a broadcast called `me.hang` by default. - You can use query parameters to use a different broadcast name and create - multiple broadcasts. -

-

- Reusing the same broadcast path means viewers will automatically reconnect to the new session. - Try reloading the page and broadcasting again; viewers will automatically reconnect. -

-
-

- Media only flows over the network when requested! - Connecting to a relay means the broadcast is advertised as available, but nothing is transferred until there's - at least one viewer per track. - If there's multiple viewers, the relay will fan out the media to all of them. -

-

- You can create a broadcaster via the provided <moq-publish> Web Component. - Either modify HTML attributes like <moq-publish source="camera" audio /> - or access the element's Javascript API: -

const publish = document.getElementById("publish");
-publish.broadcast.audio.enabled.set(true);
- - And of course you can use the Javascript API directly instead of the Web Component. - It's a bit more complicated and subject to change, but it gives you more control. -

-
-

- You're not limited to web publishing either. - Try running just pub tos in a new terminal and then watch robots bang. - This uses ffmpeg to produce a fragmented MP4 file piped over stdout then sent over the network. - Yeah it's pretty gross. -

-

- If you want to do things more efficiently, you can use the - GStreamer plugin. - It's pretty crude and doesn't handle all pipeline events; contributions welcome! -

-
-

- The simulcast attribute makes this page publish two renditions of the same - video: `video/hd` at the captured resolution and `video/sd` at a fraction of the source resolution (around 480p from a 1080p source). - Each rendition is a separate track, so it only costs bandwidth when a viewer requests it. - Tiny thumbnails download the small one; big players download the big one. -

-

- To see it in action, watch your - broadcast in another tab and drag the player's resize handle. - The "Quality" dropdown shows which rendition is active; leave it on Auto and watch it hop. - You can also toggle it from Javascript with publish.simulcast = false. -

-
-

- This demo uses `http://` so it's not secure. - It works by fetching the certificate hash (via HTTP) and providing that to WebTransport, which requires HTTPS. - To run this in production, you'll need a valid certificate (ex. letsencrypt) and to use `https://`. -

+ + + + + +
+ + + +
+ + + + +
+

How it works:

+

+ Capture, encoding, and publishing are owned by the + <moq-publish-ui> web component. The side panel writes the + encoder signals directly – codec, bitrate, resolution, frame rate, and the full Opus config + are all editable live: +

+
const publish = document.getElementById("publish");
+publish.broadcast.video.hd.config.set({ maxBitrate: 2_000_000, frameRate: 30 });
+publish.broadcast.audio.codec.set({ mime: "opus", bitrate: 64_000 });
+

+ Each setting defaults to "auto" (the field is omitted, so the encoder picks); the negotiated value + appears to the right once you go live. Codec support is probed with + VideoEncoder.isConfigSupported; unsupported options are + disabled. The simulcast attribute also publishes a smaller video/sd + rendition (the panel tunes video/hd); the watch inspector + picks whichever fits the player size. +

+

+ The metadata editor serves a custom meta.json track within the + broadcast via broadcast.net; the watch inspector subscribes and reads it back live. +

+

+ You're not limited to the browser. Try just pub tos in a terminal + and watch it appear as a tile, or use the + GStreamer plugin for native pipelines. +

+

+ This demo uses http so it's extra not secure: it insecurely fetches the certificate + hash and tells WebTransport to trust it. In production you'll need a real certificate (ex. + LetsEncrypt) and https. +

+ +

Other demos:

+ +
diff --git a/demo/web/src/publish.ts b/demo/web/src/publish.ts index 68622af53..0e23a4b38 100644 --- a/demo/web/src/publish.ts +++ b/demo/web/src/publish.ts @@ -1,20 +1,393 @@ -import "./highlight"; -import "@moq/publish/ui"; +/** + * MoQ publish demo built on the web component. + * + * The component owns capture (camera / screen / file / mic), preview, go-live, + * and mute. This demo adds on top of it: + * + * 1. A side panel of *encoder* settings. Each defaults to "auto" (the field is + * omitted so the encoder picks); we drive the broadcast's encoder signals + * directly and show the negotiated value beside each control once live. + * 2. A toggle between a raw-capture preview and an "encoded" preview that + * decodes a copy of the stream (what viewers actually receive). + * 3. A custom `meta.json` track carried *within* the broadcast. + * 4. Live graphs (capture rate, upload-bandwidth estimate, round trip). The + * publish API exposes no encoded-byte counter, so these are the honestly- + * observable signals. + */ -// We need to import Web Components with fully-qualified paths because of tree-shaking. -import MoqPublish from "@moq/publish/element"; +import "./highlight"; +import "@moq/publish/element"; // defines +import "@moq/publish/ui"; // defines +import { type Audio, Json, Net, Signals } from "@moq/publish"; +import type MoqPublish from "@moq/publish/element"; import MoqPublishSupport from "@moq/publish/support/element"; +import { formatBitrate, formatFps, graph } from "./viz"; + +/** Re-exported so bundlers keep the `` element registration. */ +export { MoqPublishSupport }; + +// Injected by Vite (see justfile). Defaults to the local relay. +const RELAY_URL = import.meta.env.VITE_RELAY_URL ?? "http://localhost:4443"; + +const $ = (id: string): T => { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el as T; +}; + +// The component builds its Broadcast in the constructor, so `.broadcast` is ready +// as soon as the element upgrades. `broadcast.video.hd` and `broadcast.audio` are +// the encoders whose signals we drive below. +const publish = $("publish"); +publish.url = RELAY_URL; + +// --------------------------------------------------------------------------- +// Connection + broadcast name (editable) +// --------------------------------------------------------------------------- + +const relayEl = $("relay-url"); +relayEl.value = RELAY_URL; +relayEl.addEventListener("change", () => { + try { + publish.url = new URL(relayEl.value.trim()); + } catch { + // Revert invalid input to the last good URL. + relayEl.value = publish.url?.toString() ?? RELAY_URL; + } +}); + +const nameEl = $("broadcast-name"); +nameEl.value = String(publish.name); +nameEl.addEventListener("change", () => { + const v = nameEl.value.trim(); + if (v) publish.name = v; +}); + +// Toggle the preview between the raw capture ("source") and a decoded copy of the +// encoded stream ("encoded"). Defaults to off (raw) to avoid the extra encode + +// decode unless the user wants to inspect codec artifacts. +const encodedEl = $("encoded-preview"); +const syncPreview = () => publish.setAttribute("preview", encodedEl.checked ? "encoded" : "source"); +encodedEl.addEventListener("change", syncPreview); +syncPreview(); + +// --------------------------------------------------------------------------- +// Encoder settings - reactive Signals the broadcast's encoders subscribe to. +// --------------------------------------------------------------------------- +// +// Video knobs default to undefined / "" meaning "auto": we omit the field so the +// encoder picks. The negotiated result shows up in the *-actual spans below. + +const codec = new Signals.Signal(undefined); +const resolution = new Signals.Signal(""); // "" => auto +const framerate = new Signals.Signal(undefined); +const bitrateKbps = new Signals.Signal(undefined); +const keyframeMs = new Signals.Signal(undefined); + +// Audio encode. Like the video knobs, undefined / "" means "auto" (omit the +// field so the encoder picks). Only Opus exists today. +const audioCodecKind = new Signals.Signal("opus"); +const volume = new Signals.Signal(1); +const sampleRate = new Signals.Signal(undefined); +const channelCount = new Signals.Signal(undefined); + +// Opus-specific knobs (the "Opus options" panel), mapping 1:1 onto OpusConfig. +const opusBitrateKbps = new Signals.Signal(undefined); +const opusFrameDuration = new Signals.Signal(undefined); // ms (2.5 to 60) +const opusComplexity = new Signals.Signal(undefined); // 0 (fast) … 10 (best) +const opusFec = new Signals.Signal(false); // in-band forward error correction +const opusPacketLoss = new Signals.Signal(undefined); // expected loss % +const opusDtx = new Signals.Signal(false); // discontinuous transmission (silence) + +const ui = new Signals.Effect(); + +// Compose the WebCodecs/MoQ video encoder config and push it onto the HD +// rendition. Undefined fields are omitted, so the encoder auto-sizes them. +ui.run((effect) => { + const res = effect.get(resolution); + const [w, h] = res ? res.split("x").map(Number) : [undefined, undefined]; + const br = effect.get(bitrateKbps); + const kf = effect.get(keyframeMs); + publish.broadcast.video.hd.config.set({ + codec: effect.get(codec), + maxPixels: w && h ? w * h : undefined, + maxBitrate: br != null ? br * 1000 : undefined, + keyframeInterval: kf != null ? (kf as Net.Time.Milli) : undefined, + frameRate: effect.get(framerate), + }); +}); + +// Audio general settings (volume gain, output sample rate, channel mix). +ui.run((effect) => { + publish.broadcast.audio.volume.set(effect.get(volume)); + publish.broadcast.audio.sampleRate.set(effect.get(sampleRate)); + publish.broadcast.audio.channelCount.set(effect.get(channelCount)); +}); + +// Compose the structured audio codec config; today only Opus. Undefined knobs +// are omitted so the encoder auto-sizes them. +ui.run((effect) => { + if (effect.get(audioCodecKind) !== "opus") return; + const bitrate = effect.get(opusBitrateKbps); + const frameDuration = effect.get(opusFrameDuration); + const complexity = effect.get(opusComplexity); + const packetLoss = effect.get(opusPacketLoss); + const config: Audio.OpusConfig = { + mime: "opus", + ...(bitrate != null ? { bitrate: bitrate * 1000 } : {}), + ...(frameDuration != null ? { frameDuration: Net.Time.Milli(frameDuration) } : {}), + ...(complexity != null ? { complexity } : {}), + ...(packetLoss != null ? { packetlossperc: packetLoss } : {}), + useinbandfec: effect.get(opusFec), + usedtx: effect.get(opusDtx), + }; + publish.broadcast.audio.codec.set(config); +}); + +// --------------------------------------------------------------------------- +// Input bindings (DOM -> Signal) +// --------------------------------------------------------------------------- + +// A required number input: ignore empty / non-numeric so typing never pushes a +// transient 0 or NaN onto the encoder. +const bindNumber = (id: string, signal: Signals.Signal) => { + const el = $(id); + const sync = () => { + const n = Number(el.value); + if (el.value.trim() !== "" && Number.isFinite(n)) signal.set(n); + }; + sync(); + el.addEventListener("input", sync); +}; + +// An optional number input where empty means "auto" (undefined). +const bindOptionalNumber = (id: string, signal: Signals.Signal) => { + const el = $(id); + const sync = () => { + const v = el.value.trim(); + const n = Number(v); + signal.set(v !== "" && Number.isFinite(n) ? n : undefined); + }; + sync(); + el.addEventListener("input", sync); +}; + +// An optional select where the empty value ("Auto") means undefined. +const bindOptionalSelect = (id: string, signal: Signals.Signal) => { + const el = $(id); + const sync = () => signal.set(el.value ? Number(el.value) : undefined); + sync(); + el.addEventListener("change", sync); +}; + +const bindCheckbox = (id: string, signal: Signals.Signal) => { + const el = $(id); + signal.set(el.checked); + el.addEventListener("change", () => signal.set(el.checked)); +}; + +const resolutionEl = $("resolution"); +resolution.set(resolutionEl.value); +resolutionEl.addEventListener("input", () => resolution.set(resolutionEl.value)); + +bindOptionalNumber("framerate", framerate); +bindOptionalNumber("bitrate", bitrateKbps); +bindOptionalNumber("keyframe", keyframeMs); +bindNumber("volume", volume); +bindOptionalSelect("samplerate", sampleRate); +bindOptionalSelect("channels", channelCount); +bindOptionalNumber("opus-bitrate", opusBitrateKbps); +bindOptionalSelect("opus-frame-duration", opusFrameDuration); +bindOptionalNumber("opus-complexity", opusComplexity); +bindOptionalNumber("opus-plc", opusPacketLoss); +bindCheckbox("opus-fec", opusFec); +bindCheckbox("opus-dtx", opusDtx); -export { MoqPublish, MoqPublishSupport }; +// Audio codec selector: drive the codec kind and show the matching options panel. +const audioCodecEl = $("audio-codec"); +const opusAdvancedEl = $("opus-advanced"); +const syncAudioCodec = () => { + audioCodecKind.set(audioCodecEl.value); + opusAdvancedEl.hidden = audioCodecEl.value !== "opus"; +}; +audioCodecEl.addEventListener("change", syncAudioCodec); +syncAudioCodec(); -const publish = document.querySelector("moq-publish") as MoqPublish; -const watch = document.getElementById("watch") as HTMLAnchorElement; -const watchName = document.getElementById("watch-name") as HTMLSpanElement; +// --------------------------------------------------------------------------- +// Codec menu - probe live support with WebCodecs +// --------------------------------------------------------------------------- -const urlParams = new URLSearchParams(window.location.search); -const name = urlParams.get("broadcast") ?? urlParams.get("name"); -if (name) { - publish.setAttribute("name", name); - watch.href = `index.html?broadcast=${name}`; - watchName.textContent = name; +const CODECS: { label: string; value: string | undefined; probe?: string }[] = [ + { label: "Auto", value: undefined }, + { label: "H.264 (AVC, baseline)", value: "avc1.42E01F", probe: "avc1.42E01F" }, + { label: "H.264 (AVC, high)", value: "avc1.640028", probe: "avc1.640028" }, + { label: "VP8", value: "vp8", probe: "vp8" }, + { label: "VP9", value: "vp09.00.10.08", probe: "vp09.00.10.08" }, + { label: "AV1", value: "av01.0.04M.08", probe: "av01.0.04M.08" }, + { label: "HEVC (H.265)", value: "hev1.1.6.L93.B0", probe: "hev1.1.6.L93.B0" }, +]; + +async function buildCodecMenu() { + const select = $("codec"); + for (const entry of CODECS) { + const option = document.createElement("option"); + option.value = entry.value ?? "auto"; + option.textContent = entry.label; + + if (entry.probe && "VideoEncoder" in globalThis) { + try { + const support = await VideoEncoder.isConfigSupported({ + codec: entry.probe, + width: 1280, + height: 720, + bitrate: 2_000_000, + framerate: 30, + }); + if (!support.supported) { + option.disabled = true; + option.textContent += " - unsupported"; + } + } catch { + option.disabled = true; + option.textContent += " - unsupported"; + } + } + select.appendChild(option); + } + + select.addEventListener("change", () => { + codec.set(select.value === "auto" ? undefined : select.value); + }); } +buildCodecMenu(); + +// --------------------------------------------------------------------------- +// Negotiated values, shown inline beside each control once live +// --------------------------------------------------------------------------- + +const setActual = (id: string, value: string | undefined) => { + $(id).textContent = value ?? ""; +}; + +// Video: the resolved encoder config (codec / resolution / fps / bitrate). +ui.run((effect) => { + const v = effect.get(publish.broadcast.video.hd.resolved); + setActual("codec-actual", v?.codec); + setActual("resolution-actual", v?.width && v?.height ? `${v.width}×${v.height}` : undefined); + setActual("framerate-actual", v?.framerate ? formatFps(v.framerate) : undefined); + setActual("bitrate-actual", v?.bitrate ? formatBitrate(v.bitrate) : undefined); + // The encoder doesn't report the negotiated keyframe interval, so show the + // configured value (defaulting to the 2s encoder default) once live. + const kf = effect.get(keyframeMs); + setActual("keyframe-actual", v ? `${(kf ?? 2000) / 1000}s` : undefined); +}); + +// Gain is a local control (not negotiated), so just echo the current value. +ui.run((effect) => { + setActual("volume-actual", `${effect.get(volume).toFixed(2)}×`); +}); + +// Audio: the resolved audio config (codec / sample rate / channels / bitrate). +ui.run((effect) => { + const a = effect.get(publish.broadcast.audio.config); + setActual("audiocodec-actual", a?.codec); + setActual("samplerate-actual", a?.sampleRate ? `${a.sampleRate} Hz` : undefined); + setActual("channels-actual", a?.numberOfChannels ? String(a.numberOfChannels) : undefined); + setActual("opusbitrate-actual", a?.bitrate ? formatBitrate(a.bitrate) : undefined); +}); + +// --------------------------------------------------------------------------- +// Custom metadata carried within the broadcast +// --------------------------------------------------------------------------- +// +// We serve the metadata as a separate `meta.json` track *within* the broadcast, +// using `broadcast.net` (the underlying producer the element exposes). `net` is +// recreated on each (re)connection, so an effect (re)creates the track and seeds +// it with the latest value; a long cache window lets a late viewer replay the +// most recent snapshot. The track is advertised in the catalog's `metadata` list +// so the watch side knows to subscribe. +const META_TRACK = "meta.json"; + +// The latest metadata, retained across reconnects so each fresh track is seeded with it. +let currentMeta: unknown = { title: "My Broadcast", location: "earth", note: "edit me" }; +let activeMeta: Json.Producer | undefined; + +const setMeta = (value: unknown) => { + currentMeta = value; + activeMeta?.update(value); +}; + +new Signals.Effect().run((effect) => { + const net = effect.get(publish.broadcast.net); + if (!net) return; + + // A day-long cache so a viewer joining long after the last edit still replays the value. + const track = net.createTrack(META_TRACK, { cache: 86_400_000 }); + effect.cleanup(() => track.close()); + + const producer = new Json.Producer(track); + producer.update(currentMeta); + activeMeta = producer; + effect.cleanup(() => { + if (activeMeta === producer) activeMeta = undefined; + }); +}); + +publish.broadcast.catalog.mutate((catalog) => { + (catalog as typeof catalog & { metadata?: string[] }).metadata = [META_TRACK]; +}); + +const metaTextEl = $("metadata"); +const metaBtn = $("send-meta"); + +metaTextEl.addEventListener("input", () => { + metaBtn.disabled = false; +}); + +metaBtn.addEventListener("click", () => { + try { + // Publishes a fresh snapshot on the meta.json track (a no-op if unchanged); the + // cache window seeds late joiners. + setMeta(JSON.parse(metaTextEl.value)); + metaTextEl.setCustomValidity(""); + metaBtn.disabled = true; + } catch (err) { + // Keep the button armed so the user can fix and retry. + metaTextEl.setCustomValidity(`invalid JSON: ${(err as Error).message}`); + metaTextEl.reportValidity(); + } +}); + +// --------------------------------------------------------------------------- +// Live graphs +// --------------------------------------------------------------------------- + +const viz = new Signals.Effect(); + +const captureGraph = graph(viz, "Capture rate", { color: "#facc15", format: formatFps }); +const uploadGraph = graph(viz, "Upload estimate", { color: "#34d399", format: formatBitrate }); +const rttGraph = graph(viz, "Round trip", { color: "#38bdf8", format: (v) => `${Math.round(v)} ms` }); +$("publish-graphs").append(captureGraph.el, uploadGraph.el, rttGraph.el); + +// Count captured frames; the publish API has no encoded-frame counter, so this +// is the capture rate feeding the encoder (a good proxy for output fps). +let frames = 0; +viz.run((effect) => { + if (effect.get(publish.broadcast.video.frame)) frames++; +}); + +let prevFrames = 0; +let prevWhen = performance.now(); +viz.interval(() => { + const now = performance.now(); + const elapsed = now - prevWhen; + captureGraph.push(elapsed > 0 ? ((frames - prevFrames) * 1000) / elapsed : undefined); + prevFrames = frames; + prevWhen = now; + + const conn = publish.connection.established.peek(); + const up = conn?.sendBandwidth?.peek() as unknown as number | undefined; + uploadGraph.push(up && up > 0 ? up : undefined); + const rtt = conn?.rtt?.peek() as unknown as number | undefined; + rttGraph.push(rtt && rtt > 0 ? rtt : undefined); +}, 250); diff --git a/demo/web/src/stats.html b/demo/web/src/stats.html new file mode 100644 index 000000000..9078b5905 --- /dev/null +++ b/demo/web/src/stats.html @@ -0,0 +1,102 @@ + + + + + + + MoQ Demo (Relay Stats) + + + + + + +

MoQ Relay Stats

+

+ Live view of every relay publishing a .stats broadcast: the whole cluster at once, not + just the relay you're connected to. Enable it with [stats] enabled = true in the relay + config. +

+ +
+ + connecting… +
+ +
+

Nodes

+

+ Every relay node publishing .stats, auto-discovered under .stats/node + (so this covers a whole cluster, not just one relay). + broadcasters/viewers/ingress/egress are external clients; + cluster in/cluster out are internal + mTLS peer traffic (the relaying between nodes that's otherwise invisible). Click a node to drill in. +

+
searching for nodes…
+
+ + + + + + +
+ Raw frames +

+	
+ +
+

Other demos:

+ +
+ + + + + diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts new file mode 100644 index 000000000..eb6760d65 --- /dev/null +++ b/demo/web/src/stats.ts @@ -0,0 +1,415 @@ +/** + * MoQ relay stats dashboard. + * + * Every relay node that enables `[stats]` publishes a broadcast at + * `.stats/node/` carrying JSON tracks that snapshot current activity. We + * auto-discover all of those nodes (announcements under `.stats/node`), so this + * works for a single relay and for a cluster alike, then aggregate each node and + * let you drill into one. + * + * The relay splits its stats by billing tier: external clients (JWT/public) and + * internal peers (mTLS / cluster-connect). It publishes a parallel set of tracks + * for each, so cluster fan-out (e.g. the hub relaying between nodes) shows up + * only in the `internal/*` tracks, never in the external numbers. + * + * Per-node tracks we read: + * publisher.json external egress (relay -> downstream viewers) + * subscriber.json external ingress (upstream publishers -> relay) + * sessions.json external sessions by auth root + * internal/publisher.json internal egress (relay -> downstream cluster peers) + * internal/subscriber.json internal ingress (upstream cluster peers -> relay) + * internal/sessions.json internal sessions by auth root + * + * Each frame is `{ "": Snapshot }`. Counters are cumulative; + * "active" = open - closed. The relay only includes currently-live entries, so + * the latest frame is a snapshot of now. + */ + +import "./highlight"; +import { Net, Signals } from "@moq/hang"; + +const RELAY_URL = import.meta.env.VITE_RELAY_URL ?? "http://localhost:4443"; + +// Broadcasts under this prefix are per-node stats broadcasts. +const STATS_PREFIX = ".stats/node"; + +const $ = (id: string): T => { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el as T; +}; + +// ---- Frame shapes (see module comment) ------------------------------------ + +interface Snapshot { + announced?: number; + announced_closed?: number; + broadcasts?: number; + broadcasts_closed?: number; + subscriptions?: number; + subscriptions_closed?: number; + bytes?: number; + frames?: number; + groups?: number; +} +type BroadcastFrame = Record; + +interface SessionCounters { + sessions?: number; + sessions_closed?: number; +} +type SessionFrame = Record; + +interface NodeStats { + egress: BroadcastFrame; // publisher.json + ingress: BroadcastFrame; // subscriber.json + sessions: SessionFrame; // sessions.json + internalEgress: BroadcastFrame; // internal/publisher.json + internalIngress: BroadcastFrame; // internal/subscriber.json + internalSessions: SessionFrame; // internal/sessions.json +} + +const active = (open?: number, closed?: number) => (open ?? 0) - (closed ?? 0); + +// Broadcasts whose path starts with "." are internal (e.g. the `.stats` feed +// this dashboard itself reads). We exclude them from the user-facing counters. +const isInternal = (path: string) => path.startsWith("."); + +// ---- State ---------------------------------------------------------------- + +// Discovered nodes -> their latest stats frames. +const nodeStats = new Signals.Signal>({}); +const selectedNode = new Signals.Signal(undefined); + +// The relay URL, editable at runtime (see the input binding below). +const relayUrl = new Signals.Signal(new URL(RELAY_URL)); +const connection = new Net.Connection.Reload({ url: relayUrl, enabled: true }); + +// ---- Discover nodes + subscribe to each ----------------------------------- + +const discovery = new Signals.Effect(); +discovery.run((effect) => { + const conn = effect.get(connection.established); + nodeStats.set({}); + if (!conn) return; + + const prefix = Net.Path.from(STATS_PREFIX); + const announced = conn.announced(prefix); + effect.cleanup(() => announced.close()); + + // One sub-effect per node so we can tear a node's subscriptions down when it + // goes away (e.g. a cluster peer disconnects). + const subs = new Map(); + effect.cleanup(() => { + for (const e of subs.values()) e.close(); + }); + + effect.spawn(async () => { + for (;;) { + const entry = await Promise.race([effect.cancel, announced.next()]); + if (!entry) break; + const node = (Net.Path.stripPrefix(prefix, entry.path) ?? entry.path) as string; + if (!node) continue; + + if (entry.active) { + if (subs.has(node)) continue; + const ne = new Signals.Effect(); + subs.set(node, ne); + subscribeNode(ne, conn, entry.path, node); + } else { + subs.get(node)?.close(); + subs.delete(node); + nodeStats.mutate((s) => { + delete s[node]; + }); + } + } + }); +}); + +function subscribeNode(effect: Signals.Effect, conn: Net.Connection.Established, path: Net.Path.Valid, node: string) { + nodeStats.mutate((s) => { + s[node] = { + egress: {}, + ingress: {}, + sessions: {}, + internalEgress: {}, + internalIngress: {}, + internalSessions: {}, + }; + }); + + const consumer = conn.consume(path); + effect.cleanup(() => consumer.close()); + + const sub = (trackName: string, key: K) => { + const track = consumer.subscribe(trackName, 0); + effect.cleanup(() => track.close()); + effect.spawn(async () => { + for (;;) { + const data = await Promise.race([effect.cancel, track.readJson()]); + if (data === undefined) break; + nodeStats.mutate((s) => { + const cur = s[node]; + if (cur) cur[key] = (data ?? {}) as NodeStats[K]; + }); + } + }); + }; + + sub("publisher.json", "egress"); + sub("subscriber.json", "ingress"); + sub("sessions.json", "sessions"); + sub("internal/publisher.json", "internalEgress"); + sub("internal/subscriber.json", "internalIngress"); + sub("internal/sessions.json", "internalSessions"); +} + +// ---- Aggregation ---------------------------------------------------------- + +// Aggregate one ingress/egress pair (either the external or the internal tier). +// `.`-prefixed system broadcasts (the `.stats` feed itself) are excluded either +// way; we only count real content, including its cluster fan-out. +function aggregatePair(ingress: BroadcastFrame, egress: BroadcastFrame) { + let broadcasters = 0; // active broadcasts being published (ingress) + let viewers = 0; // active downstream consumers (egress) + let ingressBytes = 0; + let egressBytes = 0; + + for (const [path, s] of Object.entries(ingress)) { + if (isInternal(path)) continue; + if (active(s.announced, s.announced_closed) > 0) broadcasters++; + ingressBytes += s.bytes ?? 0; + } + for (const [path, s] of Object.entries(egress)) { + if (isInternal(path)) continue; + egressBytes += s.bytes ?? 0; + viewers += active(s.broadcasts, s.broadcasts_closed); + } + return { broadcasters, viewers, ingressBytes, egressBytes }; +} + +function aggregate(stats: NodeStats) { + return { + external: aggregatePair(stats.ingress, stats.egress), + internal: aggregatePair(stats.internalIngress, stats.internalEgress), + }; +} + +// ---- Render --------------------------------------------------------------- + +const ui = new Signals.Effect(); + +// Relay URL is editable: committing a new value reconnects the dashboard. +const relayEl = $("relay-url"); +relayEl.value = RELAY_URL; +ui.run((effect) => { + effect.event(relayEl, "change", () => { + try { + relayUrl.set(new URL(relayEl.value.trim())); + } catch { + // Revert invalid input to the last good URL. + relayEl.value = relayUrl.peek()?.toString() ?? RELAY_URL; + } + }); +}); + +ui.run((effect) => { + const status = effect.get(connection.status); + const el = $("status"); + el.textContent = status; + const color = + status === "connected" + ? "text-emerald-400 border-emerald-700" + : status === "connecting" + ? "text-amber-400 border-amber-700" + : "text-red-400 border-red-700"; + el.className = `inline-flex items-center px-2 py-1 rounded text-xs bg-neutral-900 border ${color}`; +}); + +// Keep a valid selection: default to the first node, switch away from one that +// disappears. +ui.run((effect) => { + const nodes = Object.keys(effect.get(nodeStats)).sort(); + const cur = selectedNode.peek(); + if (cur && nodes.includes(cur)) return; + selectedNode.set(nodes[0]); +}); + +// Aggregate table: one row per node, click to drill in. +ui.run((effect) => { + const all = effect.get(nodeStats); + const sel = effect.get(selectedNode); + const nodes = Object.keys(all).sort(); + const el = $("nodes"); + + if (nodes.length === 0) { + el.textContent = "searching for nodes…"; + return; + } + + const headers = ["node", "broadcasters", "viewers", "ingress", "egress", "cluster in", "cluster out"]; + const rows = nodes.map((node) => { + const a = aggregate(all[node] as NodeStats); + return { + key: node, + cells: [ + node, + String(a.external.broadcasters), + String(a.external.viewers), + formatBytes(a.external.ingressBytes), + formatBytes(a.external.egressBytes), + formatBytes(a.internal.ingressBytes), + formatBytes(a.internal.egressBytes), + ], + }; + }); + renderTable(effect, el, headers, rows, { selected: sel, onClick: (k) => selectedNode.set(k) }); +}); + +// Drill-down: the selected node's broadcasts (egress-focused) + sessions. +ui.run((effect) => { + const all = effect.get(nodeStats); + const node = effect.get(selectedNode); + const detail = $("node-detail"); + const stats = node ? all[node] : undefined; + + if (!node || !stats) { + detail.hidden = true; + return; + } + detail.hidden = false; + $("node-title").textContent = node; + + // Broadcasters: what this node ingests (from upstream publishers / cluster peers). + const ingressRows = (frame: BroadcastFrame) => + Object.keys(frame) + .filter((p) => !isInternal(p)) + .sort() + .map((path) => { + const i = frame[path] ?? {}; + return { + key: path, + cells: [path, formatBytes(i.bytes ?? 0), String(i.frames ?? 0), String(i.groups ?? 0)], + }; + }); + + // Viewers: what this node serves downstream (to subscribers / cluster peers). + const egressRows = (frame: BroadcastFrame) => + Object.keys(frame) + .filter((p) => !isInternal(p)) + .sort() + .map((path) => { + const e = frame[path] ?? {}; + return { + key: path, + cells: [ + path, + String(active(e.broadcasts, e.broadcasts_closed)), // viewers / peers + String(active(e.subscriptions, e.subscriptions_closed)), // track subs + formatBytes(e.bytes ?? 0), // egress + String(e.frames ?? 0), + String(e.groups ?? 0), + ], + }; + }); + + const inHeaders = ["broadcast", "ingress", "frames", "groups"]; + const outHeaders = ["broadcast", "viewers", "track subs", "egress", "frames", "groups"]; + + renderTable(effect, $("node-publishers"), inHeaders, ingressRows(stats.ingress)); + renderTable(effect, $("node-subscribers"), outHeaders, egressRows(stats.egress)); + renderTable( + effect, + $("node-internal-publishers"), + ["broadcast", "ingress", "frames", "groups"], + ingressRows(stats.internalIngress), + ); + renderTable( + effect, + $("node-internal-subscribers"), + ["broadcast", "peers", "track subs", "egress", "frames", "groups"], + egressRows(stats.internalEgress), + ); + + const countSessions = (f: SessionFrame) => + Object.values(f).reduce((n, s) => n + active(s.sessions, s.sessions_closed), 0); + const sessions = countSessions(stats.sessions); + const internalSessions = countSessions(stats.internalSessions); + $("node-sessions").textContent = `${sessions} external session${sessions === 1 ? "" : "s"}`; + $("node-internal-sessions").textContent = `${internalSessions} cluster session${internalSessions === 1 ? "" : "s"}`; +}); + +// Raw frames for everyone who wants the numbers behind the tables. +ui.run((effect) => { + $("raw").textContent = JSON.stringify(effect.get(nodeStats), null, 2); +}); + +// ---- Helpers -------------------------------------------------------------- + +interface Row { + key: string; + cells: string[]; +} + +function renderTable( + effect: Signals.Effect, + container: HTMLElement, + headers: string[], + rows: Row[], + opts?: { selected?: string; onClick?: (key: string) => void }, +) { + if (rows.length === 0) { + container.textContent = "no active entries"; + return; + } + const table = document.createElement("table"); + table.className = "w-full text-sm border-collapse"; + + const thead = document.createElement("thead"); + const htr = document.createElement("tr"); + for (const h of headers) { + const th = document.createElement("th"); + th.className = "text-left font-medium text-neutral-400 px-2 py-1 border-b border-neutral-700"; + th.textContent = h; + htr.appendChild(th); + } + thead.appendChild(htr); + table.appendChild(thead); + + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + tr.className = "border-b border-neutral-800"; + if (opts?.onClick) { + tr.classList.add("cursor-pointer", "hover:bg-neutral-800"); + tr.tabIndex = 0; + tr.setAttribute("role", "button"); + if (row.key === opts.selected) tr.classList.add("bg-neutral-800", "text-emerald-300"); + const activate = () => opts.onClick?.(row.key); + effect.event(tr, "click", activate); + effect.event(tr, "keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + activate(); + } + }); + } + for (const cell of row.cells) { + const td = document.createElement("td"); + td.className = "px-2 py-1 font-mono text-neutral-200 whitespace-nowrap"; + td.textContent = cell; + tr.appendChild(td); + } + tbody.appendChild(tr); + } + table.appendChild(tbody); + container.replaceChildren(table); +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} diff --git a/demo/web/src/viz.ts b/demo/web/src/viz.ts new file mode 100644 index 000000000..1bb85e46a --- /dev/null +++ b/demo/web/src/viz.ts @@ -0,0 +1,347 @@ +/** + * Stats visualizations for the watch inspector, ported from the private internals + * of `` so the demo is self-contained. They read only the public + * `MoqWatch.backend` signals, so this doubles as an example of building your own + * charts on top of the API. + * + * - `graph()` is a rolling sparkline (used for bitrate / frame rate). + * - `bufferBars()` is an editable view of the video/audio jitter buffer; drag it + * to change the latency target. + */ + +import type { BufferedRanges, Signals } from "@moq/watch"; +import type MoqWatch from "@moq/watch/element"; + +export interface GraphOptions { + /** Fixed y-axis maximum. If omitted, the graph autoscales to its rolling peak. */ + max?: number; + /** How many samples of history to retain (older samples scroll off the left). */ + samples?: number; + /** Stroke/fill color for the line (any CSS color). */ + color?: string; + /** Formats the latest value for the readout in the corner. */ + format?: (v: number) => string; +} + +export interface Graph { + /** The graph's root element; append it where you want the chart to render. */ + el: HTMLElement; + /** Append a sample. Pass undefined to record a gap (drawn as zero). */ + push(value: number | undefined): void; +} + +const DEFAULT_SAMPLES = 120; + +/** Normalize any CSS color and apply an alpha, so the gradient works for named/rgb/hsl inputs too. */ +function withAlpha(color: string, alpha: number): string { + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) return color; + ctx.fillStyle = color; + const normalized = ctx.fillStyle; + if (normalized.startsWith("#")) { + const n = Number.parseInt(normalized.slice(1), 16); + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; + } + const parts = normalized.match(/[\d.]+/g); + if (parts && parts.length >= 3) return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`; + return color; +} + +/** + * A rolling time-series sparkline. Samples scroll right-to-left and the area + * under the line is filled with a fading gradient. Redraws are event-driven: + * each `push` and any canvas resize triggers a repaint (no animation loop). + */ +export function graph(parent: Signals.Effect, title: string, opts?: GraphOptions): Graph { + const color = opts?.color ?? "#4ade80"; + const fillTop = withAlpha(color, 0.33); + const fillBottom = withAlpha(color, 0); + const capacity = Number.isFinite(opts?.samples) + ? Math.max(1, Math.floor(opts?.samples as number)) + : DEFAULT_SAMPLES; + + const el = document.createElement("div"); + + const header = document.createElement("div"); + header.className = "flex justify-between text-xs text-neutral-400"; + const label = document.createElement("span"); + label.textContent = title; + const value = document.createElement("span"); + value.className = "font-mono"; + value.style.color = color; + value.textContent = "—"; + header.append(label, value); + + const canvas = document.createElement("canvas"); + canvas.style.cssText = "display: block; width: 100%; height: 40px;"; + + el.append(header, canvas); + + const samples: number[] = []; + let scale = opts?.max ?? 1; + + const draw = () => { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + const w = rect.width; + const h = rect.height; + const cw = Math.round(w * dpr); + const ch = Math.round(h * dpr); + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw; + canvas.height = ch; + } + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + if (w <= 0 || h <= 0 || samples.length <= 1) return; + + const peak = Math.max(...samples); + const target = opts?.max ?? Math.max(1, peak * 1.2); + scale += (target - scale) * 0.1; + + const pad = 1; + const usable = h - pad * 2; + const x = (i: number) => (capacity <= 1 ? w : (i / (capacity - 1)) * w); + const y = (v: number) => pad + usable - (Math.min(v, scale) / scale) * usable; + const offset = capacity - samples.length; + + ctx.beginPath(); + ctx.moveTo(x(offset), y(samples[0])); + for (let i = 1; i < samples.length; i++) ctx.lineTo(x(offset + i), y(samples[i])); + const grad = ctx.createLinearGradient(0, 0, 0, h); + grad.addColorStop(0, fillTop); + grad.addColorStop(1, fillBottom); + ctx.save(); + ctx.lineTo(x(offset + samples.length - 1), h); + ctx.lineTo(x(offset), h); + ctx.closePath(); + ctx.fillStyle = grad; + ctx.fill(); + ctx.restore(); + + ctx.beginPath(); + ctx.moveTo(x(offset), y(samples[0])); + for (let i = 1; i < samples.length; i++) ctx.lineTo(x(offset + i), y(samples[i])); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.lineJoin = "round"; + ctx.stroke(); + }; + + const push = (v: number | undefined) => { + samples.push(v !== undefined && Number.isFinite(v) ? Math.max(0, v) : 0); + while (samples.length > capacity) samples.shift(); + value.textContent = v !== undefined && Number.isFinite(v) ? (opts?.format?.(v) ?? v.toFixed(0)) : "—"; + draw(); + }; + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(() => draw()); + observer.observe(canvas); + parent.cleanup(() => observer.disconnect()); + } + + return { el, push }; +} + +const BUFFER_MAX = 4000; // window shown, in milliseconds + +/** Draw one track's buffered ranges relative to the current playhead (timestamp). */ +function drawRanges( + canvas: HTMLCanvasElement, + ranges: BufferedRanges, + timestamp: number | undefined, + stalled: boolean, +) { + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + const width = rect.width; + const height = rect.height; + const cw = Math.round(width * dpr); + const ch = Math.round(height * dpr); + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw; + canvas.height = ch; + } + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + if (timestamp === undefined) return; + + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i]; + const startMs = range.start - timestamp; + const endMs = range.end - timestamp; + const visibleStart = Math.max(0, startMs); + const visibleEnd = Math.min(endMs, BUFFER_MAX); + if (visibleEnd <= visibleStart) continue; + + const x = (visibleStart / BUFFER_MAX) * width; + const w = Math.max(2, ((visibleEnd - visibleStart) / BUFFER_MAX) * width); + + ctx.globalAlpha = 0.85; + // red while buffering, yellow for extra ranges, green for the main one. + ctx.fillStyle = stalled ? "#f87171" : i > 0 ? "#facc15" : "#4ade80"; + if (typeof ctx.roundRect === "function") { + ctx.beginPath(); + ctx.roundRect(x, 1, w, height - 2, 2); + ctx.fill(); + } else { + ctx.fillRect(x, 1, w, height - 2); + } + } +} + +const STEP = 10; // latency drag/keyboard granularity, in milliseconds + +/** + * Editable latency visualization: video + audio jitter buffers drawn as bars + * from the playhead (left edge) out to ~4s, with the current latency target + * marked. Drag (or focus + arrow keys) to set the buffer, like ``. + * Each call binds to one `watch`; close the parent effect to stop it. + */ +export function bufferBars(parent: Signals.Effect, watch: MoqWatch): HTMLElement { + const root = document.createElement("div"); + + const viz = document.createElement("div"); + viz.style.cssText = "position: relative; cursor: ew-resize;"; + viz.tabIndex = 0; + viz.setAttribute("role", "slider"); + viz.setAttribute("aria-label", "Latency target"); + viz.setAttribute("aria-valuemin", "0"); + viz.setAttribute("aria-valuemax", String(BUFFER_MAX)); + + const mkTrack = (name: string) => { + const row = document.createElement("div"); + row.className = "flex items-center gap-2"; + const label = document.createElement("span"); + label.className = "w-10 shrink-0 text-[10px] text-neutral-500"; + label.textContent = name; + const canvas = document.createElement("canvas"); + canvas.style.cssText = "display: block; flex: 1; height: 20px;"; + row.append(label, canvas); + return { row, canvas }; + }; + + const video = mkTrack("video"); + const audio = mkTrack("audio"); + + // Vertical target line over the canvas region (offset past the label + gap). + const target = document.createElement("div"); + target.style.cssText = "position: absolute; top: 0; bottom: 0; width: 2px; background: #fff; pointer-events: none;"; + const targetLabel = document.createElement("span"); + targetLabel.className = "text-[10px] text-neutral-300"; + targetLabel.style.cssText = "position: absolute; top: -2px; left: 4px; white-space: nowrap;"; + target.appendChild(targetLabel); + + // pointer-events: none so clicks pass through to `viz` for the drag handler. + const canvasArea = document.createElement("div"); + canvasArea.style.cssText = "position: absolute; left: 3rem; right: 0; top: 0; bottom: 0; pointer-events: none;"; + canvasArea.appendChild(target); + + const space = document.createElement("div"); + space.className = "space-y-1"; + space.append(video.row, audio.row); + viz.append(space, canvasArea); + + const legend = document.createElement("div"); + legend.className = "mt-2 text-[10px] text-neutral-500"; + legend.textContent = "buffered ahead of the playhead; drag to change the latency target"; + + root.append(viz, legend); + + // Set the latency floor, leaving the ceiling. Cast bypasses the branded ms type. + const setLatency = (ms: number) => { + const clamped = Math.max(0, Math.min(BUFFER_MAX, ms)); + watch.latencyMin = clamped as unknown as typeof watch.latencyMin; + }; + + const setFromX = (clientX: number) => { + const rect = canvasArea.getBoundingClientRect(); + if (rect.width <= 0) return; + const x = Math.max(0, Math.min(clientX - rect.left, rect.width)); + const ms = (x / rect.width) * BUFFER_MAX; + setLatency(Math.round(ms / STEP) * STEP); + }; + + let dragging = false; + parent.event(viz, "mousedown", (e) => { + dragging = true; + setFromX(e.clientX); + }); + parent.event(document, "mousemove", (e) => { + if (dragging) setFromX(e.clientX); + }); + parent.event(document, "mouseup", () => { + dragging = false; + }); + parent.event(viz, "keydown", (e) => { + const delta = + e.key === "ArrowRight" || e.key === "ArrowUp" + ? STEP + : e.key === "ArrowLeft" || e.key === "ArrowDown" + ? -STEP + : 0; + if (delta === 0) return; + e.preventDefault(); + setLatency((watch.backend.output.jitter.peek() as unknown as number) + delta); + }); + + // Position the target line from the live jitter (actual measured buffer in ms). + parent.run((effect) => { + const jitter = effect.get(watch.backend.output.jitter) as unknown as number; + const pct = Math.max(0, Math.min(1, jitter / BUFFER_MAX)) * 100; + target.style.left = `${pct}%`; + targetLabel.textContent = `${Math.round(jitter)}ms`; + viz.setAttribute("aria-valuenow", String(Math.round(jitter))); + }); + + // Repaint the bars every animation frame; cleaned up when the effect closes. + const draw = () => { + const timestamp = watch.backend.sync.now() as number | undefined; + const stalled = watch.backend.video.output.stalled.peek(); + drawRanges(video.canvas, watch.backend.video.output.buffered.peek(), timestamp, stalled); + drawRanges(audio.canvas, watch.backend.audio.output.buffered.peek(), timestamp, false); + parent.animate(draw); + }; + parent.animate(draw); + + return root; +} + +/** Format a bits-per-second value as kbps / Mbps. */ +export function formatBitrate(bps: number): string { + if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`; + return `${Math.round(bps / 1000)} kbps`; +} + +/** Format frames-per-second. */ +export function formatFps(v: number): string { + return `${v.toFixed(0)} fps`; +} + +/** A key/value row for the stat panels. */ +export function kv(key: string, value: string): HTMLElement { + const row = document.createElement("div"); + row.className = "flex justify-between gap-4 text-sm"; + const k = document.createElement("span"); + k.className = "text-neutral-400"; + k.textContent = key; + const v = document.createElement("span"); + v.className = "font-mono text-neutral-100 text-right break-all"; + v.textContent = value; + row.append(k, v); + return row; +} + +/** Render key/value rows, skipping any whose value is undefined (we don't show a stat we don't know). */ +export function renderRows(container: HTMLElement, rows: [string, string | undefined][]): void { + const known = rows.filter((r): r is [string, string] => r[1] !== undefined); + container.replaceChildren(...known.map(([k, v]) => kv(k, v))); +} diff --git a/demo/web/vite.config.ts b/demo/web/vite.config.ts index 1ecaad0f1..532576617 100644 --- a/demo/web/vite.config.ts +++ b/demo/web/vite.config.ts @@ -4,11 +4,19 @@ import { defineConfig } from "vite"; import solidPlugin from "vite-plugin-solid"; import { workletInline } from "../../js/common/vite-plugin-worklet"; import { consoleOverlay } from "./console-overlay"; +import { openTabs } from "./open-tabs"; export default defineConfig({ root: "src", envDir: resolve(__dirname), - plugins: [tailwindcss(), solidPlugin(), workletInline(), consoleOverlay()], + plugins: [ + tailwindcss(), + solidPlugin(), + workletInline(), + consoleOverlay(), + // Open the watch and publish demos each in their own tab. + openTabs(["index.html", "publish.html"]), + ], build: { target: "esnext", sourcemap: process.env.NODE_ENV === "production" ? false : "inline", @@ -16,8 +24,7 @@ export default defineConfig({ input: { watch: resolve(__dirname, "src/index.html"), publish: resolve(__dirname, "src/publish.html"), - mse: resolve(__dirname, "src/mse.html"), - manual: resolve(__dirname, "src/manual.html"), + stats: resolve(__dirname, "src/stats.html"), }, }, }, diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 80074746c..d99766a02 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -35,7 +35,11 @@ nix build github:moq-dev/moq#moq-cli ```bash docker pull moqdev/moq-cli -docker run -v "$(pwd)/video.mp4:/app/video.mp4:ro" moqdev/moq-cli publish /app/video.mp4 https://relay.example.com/anon/stream + +# moq-cli reads media from stdin, so pipe an MPEG-TS stream into the container. +# `-i` forwards stdin to the container process. +ffmpeg -i video.mp4 -c copy -f mpegts - | \ + docker run -i moqdev/moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub](https://hub.docker.com/r/moqdev/moq-cli). @@ -52,10 +56,17 @@ The binary will be in `target/release/moq-cli`. ## Basic Usage +`moq-cli publish` reads media from stdin and selects the input container with a +subcommand (`ts`, `fmp4`, `flv`, `avc3`, `hls`). The destination is set with +`--url` (the server) and `--broadcast` (the broadcast name), not a path on the URL. + ### Publish a Video File +Remux a file to MPEG-TS and pipe it in (`-c copy` avoids re-encoding): + ```bash -moq-cli publish video.mp4 https://relay.example.com/anon/my-stream +ffmpeg -i video.mp4 -c copy -f mpegts - | \ + moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` ### Publish from FFmpeg @@ -63,7 +74,7 @@ moq-cli publish video.mp4 https://relay.example.com/anon/my-stream Pipe FFmpeg output directly to moq-cli: ```bash -ffmpeg -i input.mp4 -f mpegts - | moq-cli publish - https://relay.example.com/anon/my-stream +ffmpeg -i input.mp4 -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` ### Capture a Webcam @@ -111,20 +122,20 @@ Alternatively, pipe an external FFmpeg process as MPEG-TS: ```bash # macOS -ffmpeg -f avfoundation -i "0:0" -f mpegts - | moq-cli publish - https://relay.example.com/anon/webcam +ffmpeg -f avfoundation -i "0:0" -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast webcam ts # Linux -ffmpeg -f v4l2 -i /dev/video0 -f mpegts - | moq-cli publish - https://relay.example.com/anon/webcam +ffmpeg -f v4l2 -i /dev/video0 -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast webcam ts ``` ### Publish Screen ```bash # macOS -ffmpeg -f avfoundation -i "1:" -f mpegts - | moq-cli publish - https://relay.example.com/anon/screen +ffmpeg -f avfoundation -i "1:" -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast screen ts # Linux (X11) -ffmpeg -f x11grab -i :0.0 -f mpegts - | moq-cli publish - https://relay.example.com/anon/screen +ffmpeg -f x11grab -i :0.0 -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast screen ts ``` ## Encoding Options @@ -136,7 +147,7 @@ ffmpeg -i input.mp4 \ -c:v libx264 -preset ultrafast -tune zerolatency \ -b:v 2500k -maxrate 2500k -bufsize 5000k \ -c:a aac -b:a 128k \ - -f mpegts - | moq-cli publish - https://relay.example.com/anon/stream + -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` ### Low Latency Settings @@ -146,7 +157,7 @@ ffmpeg -i input.mp4 \ -c:v libx264 -preset ultrafast -tune zerolatency \ -g 30 -keyint_min 30 \ -c:a aac \ - -f mpegts - | moq-cli publish - https://relay.example.com/anon/stream + -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` ### H.265/HEVC @@ -155,7 +166,7 @@ ffmpeg -i input.mp4 \ ffmpeg -i input.mp4 \ -c:v libx265 -preset ultrafast \ -c:a aac \ - -f mpegts - | moq-cli publish - https://relay.example.com/anon/stream + -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` ## Container Formats @@ -203,6 +214,13 @@ rather than mis-described. The catalog describes the codec honestly so a subscriber that can decode it (typically TS gear) picks it up; browsers cannot play these codecs and should skip the rendition. +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 +forwards them transparently and never parses the payload. + ### FLV Ingest an FLV stream from FFmpeg and play one back out: @@ -224,10 +242,11 @@ are not supported. ## Authentication -Pass a JWT token via the URL: +Pass a JWT token via the URL's `?jwt=` query parameter: ```bash -moq-cli publish video.mp4 "https://relay.example.com/room/123?jwt=" +ffmpeg -i video.mp4 -c copy -f mpegts - | \ + moq-cli publish --url "https://relay.example.com/?jwt=" --broadcast my-stream ts ``` See [Authentication](/bin/relay/auth) for token generation. @@ -250,10 +269,10 @@ Publish and subscribe to clock broadcasts for testing: ```bash # Publish a clock -just clock publish https://relay.example.com/anon +just pub clock publish https://relay.example.com/anon # Subscribe to a clock -just clock subscribe https://relay.example.com/anon +just pub clock subscribe https://relay.example.com/anon ``` ## Debugging @@ -261,7 +280,8 @@ just clock subscribe https://relay.example.com/anon ### Verbose Output ```bash -RUST_LOG=debug moq-cli publish video.mp4 https://relay.example.com/anon/stream +ffmpeg -i video.mp4 -c copy -f mpegts - | \ + RUST_LOG=debug moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` ### Check Connection diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index 8f8d6d3d8..2c7966a13 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -58,11 +58,15 @@ Lists `moqsink` and `moqsrc`. As a one-liner: `nix run github:moq-dev/moq#moq-gs ```bash nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -v -e \ - moqsrc url=https://cdn.moq.dev/demo broadcast=bbb.hang \ - ! decodebin3 ! videoconvert ! autovideosink + moqsrc name=s url=https://cdn.moq.dev/demo broadcast=bbb.hang \ + s.video_0 ! queue ! decodebin3 ! videoconvert ! autovideosink \ + s.audio_0 ! queue ! decodebin3 ! audioconvert ! autoaudiosink ``` -Audio-only variant: swap the tail for `! decodebin3 ! audioconvert ! autoaudiosink`. +`bbb.hang` carries both video and audio, so each is linked by pad name (`video_0` / +`audio_0`). For video only, drop the `s.audio_0` branch; the audio pad simply stays +unlinked. The terse `moqsrc ! decodebin3 ! ...` form links just the first pad GStreamer +offers, which on a multi-track broadcast may be the audio one, so prefer naming the pad. ### Publish your own broadcast @@ -185,6 +189,22 @@ gst-launch-1.0 -v -e \ ! decodebin3 ! videoconvert ! autovideosink ``` +::: warning +`moqsrc` exposes one source pad per rendition: `video_0`, `audio_0`, and so on +(see [moqsrc pads](#moqsrc-subscribe)). The single-branch `moqsrc ! decodebin3 ...` +above only links the *first* pad GStreamer offers, so on a broadcast with both video +and audio it may pick up the audio pad and a video-only sink chain then renders nothing. +Link the pad you want by name, and route the rest to a sink so they don't stall: + +```bash +gst-launch-1.0 -v -e moqsrc name=s url="http://localhost:4443" broadcast="bbb" \ + s.video_0 ! queue ! decodebin3 ! videoconvert ! autovideosink \ + s.audio_0 ! queue ! decodebin3 ! audioconvert ! autoaudiosink +``` + +The first pad of each kind is always `video_0` / `audio_0` regardless of catalog order. +::: + ## Supported Codecs ### moqsink (publish) @@ -203,6 +223,13 @@ gst-launch-1.0 -v -e \ Outputs the same caps based on the catalog, compatible with `decodebin3`. +One source pad is created per rendition, named after its kind: `video_0`, `video_1`, +`audio_0`, and so on. The first pad of each kind is always numbered `0`, so a +`gst-launch` pipeline can link the stream it wants by name (`moqsrc name=s s.video_0 ! ...`) +no matter which rendition the catalog announces first. Pads appear once their rendition +shows up in the catalog (sometimes-pads), so an application links them from a +`pad-added` handler. + ## Debugging Enable GStreamer debug output: diff --git a/doc/bin/index.md b/doc/bin/index.md index cfc4df9f0..021448115 100644 --- a/doc/bin/index.md +++ b/doc/bin/index.md @@ -26,7 +26,7 @@ Another tool does the encoding (ex. ffmpeg), making it easy to pipe any media in ```bash # Publish your webcam -ffmpeg -f avfoundation -i "0" -f mp4 - | moq-cli publish https://relay.example.com my-stream +ffmpeg -f avfoundation -i "0" -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` ## [moq-rtc](/bin/rtc) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index a0c9111ad..c4655ae9c 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -236,6 +236,73 @@ peers to discover and dial. The `quinn` and `noq` QUIC backends support mTLS; configuring `tls.root` with a backend that does not (e.g. `quiche`) is a startup error. +## Internal Listener + +For trusted local workers that don't want the overhead of TLS or UDP, the relay +can bind a second listener that speaks the qmux wire format directly over a plain +stream. It performs **no token/certificate authentication**: every accepted +connection is granted full, unrestricted publish and subscribe access on the +internal tier, exactly like a cluster peer dialing `/`. + +A TCP and a Unix-socket listener can each be enabled independently, under +`[internal.tcp]` and `[internal.uds]`. + +### TCP + +```toml +[internal.tcp] +# Plain-TCP qmux listener. No TLS, no UDP, no auth: anyone who can reach this +# socket gets full access. Bind it only to a trusted interface. +listen = "127.0.0.1:4444" +``` + +TCP carries no peer identity, so **any local process of any user** can connect. +Loopback is the safest bind; a private VPC interface is also valid. The relay +logs a warning when `listen` is not a loopback address but does not refuse to +start, so firewalling the port is your responsibility. + +```bash +moq publish tcp://127.0.0.1:4444/my-broadcast < video.mp4 +``` + +### Unix socket (with a uid/gid/pid allowlist) + +A Unix socket lets the relay authenticate the connecting process by its kernel +credentials (`SO_PEERCRED` / `LOCAL_PEERCRED`), so you can restrict access to a +specific worker user rather than any local process. Requires the relay to be +built with the `uds` feature. + +```toml +[internal.uds] +listen = "/run/moq/internal.sock" + +# Only accept connections from these credentials. Each list is matched +# independently (AND across fields, OR within a field); an empty/omitted list +# imposes no constraint on that field. +[internal.uds.allow] +uid = [1001] # only the worker's user +# gid = [2000] +# pid = [12345] +``` + +A connection whose credentials fail the allowlist is closed immediately with no +access granted. A `pid` requirement rejects peers whose PID the platform doesn't +report (e.g. some macOS versions). With no `allow` list configured the socket is +unauthenticated (the relay logs a warning), so the socket's filesystem +permissions become the only gate. + +```bash +moq publish unix:///run/moq/internal.sock --broadcast my-broadcast < video.mp4 +``` + +### Notes + +Both transports are native-only: browsers can't open raw TCP or Unix sockets, so +the JS client doesn't support them. The plain-stream path has no TLS ALPN, so the +MoQ version is negotiated in-band via qmux (a transport parameter on the first +frame) and the exact version is agreed up front. Neither transport reads a path +from the URL for routing; the grant is always the empty root (everything). + ## Example Configurations See the [`demo/relay/`](https://github.com/moq-dev/moq/tree/main/demo/relay) directory for complete working configuration files, including authentication setup: diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index c507cdd9a..ee4d60a42 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -5,12 +5,10 @@ description: TOML configuration reference for moq-relay # Configuration -moq-relay is configured via a TOML file. Pass the path as the only argument: +moq-relay is configured via a TOML file. Pass the path as the only positional argument: ```bash moq-relay relay.toml -# or -moq-relay --config relay.toml ``` ## Minimal Example @@ -96,6 +94,31 @@ cert = "cert.pem" key = "key.pem" ``` +### \[internal] + +Unauthenticated qmux listeners (no TLS) for trusted local workers. Every +connection is granted full, unrestricted access. A TCP and a Unix-socket +listener can each be enabled independently; both default to disabled. + +```toml +# Plain-TCP listener (tcp:// scheme). +[internal.tcp] +listen = "127.0.0.1:4444" + +# Unix-socket listener (unix:// scheme), requires the `uds` build feature. +[internal.uds] +listen = "/run/moq/internal.sock" + +# Restrict the Unix-socket callers by peer credentials. Empty/omitted = no check. +[internal.uds.allow] +uid = [1001] +# gid = [2000] +# pid = [12345] +``` + +A non-loopback TCP bind logs a warning but is allowed. See +[Internal Listener](/bin/relay/auth#internal-listener) for details. + ### \[auth] Authentication configuration. diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md index a939b9b38..5a9deb11e 100644 --- a/doc/bin/relay/index.md +++ b/doc/bin/relay/index.md @@ -57,7 +57,7 @@ nix build github:moq-dev/moq#moq-relay ```bash docker pull moqdev/moq-relay -docker run -p 4443:4443/udp -v "$(pwd)/relay.toml:/app/relay.toml:ro" moqdev/moq-relay -- --config /app/relay.toml +docker run -p 4443:4443/udp -v "$(pwd)/relay.toml:/app/relay.toml:ro" moqdev/moq-relay -- /app/relay.toml ``` Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub](https://hub.docker.com/r/moqdev/moq-relay). @@ -83,11 +83,7 @@ See [localhost.toml](https://github.com/moq-dev/moq/blob/main/demo/relay/localho ## Running -```bash -moq-relay --config relay.toml -``` - -Or with the config path as the only argument: +Pass the config path as the only positional argument: ```bash moq-relay relay.toml diff --git a/doc/lib/js/@moq/demo.md b/doc/lib/js/@moq/demo.md index e96c117de..1bcb0c857 100644 --- a/doc/lib/js/@moq/demo.md +++ b/doc/lib/js/@moq/demo.md @@ -15,7 +15,7 @@ Follow the [Quick Start](/setup/) guide to get started. You can target a remote relay instead of a local one with the command: ```bash -just web https://cdn.moq.dev/anon +just web serve https://cdn.moq.dev/anon ``` ## Watch Demo diff --git a/doc/lib/rs/crate/hang.md b/doc/lib/rs/crate/hang.md index ae0e8b86c..cd266bc01 100644 --- a/doc/lib/rs/crate/hang.md +++ b/doc/lib/rs/crate/hang.md @@ -132,14 +132,13 @@ The `moq-cli` package provides a command-line tool (binary name: `moq-cli`): # Install cargo install moq-cli -# Publish a video file -moq-cli publish video.mp4 +# Publish a video file (remux to MPEG-TS and pipe it in) +ffmpeg -i input.mp4 -c copy -f mpegts - | \ + moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts # Publish from FFmpeg -ffmpeg -i input.mp4 -f mpegts - | moq-cli publish - - -# Custom encoding settings -moq-cli publish --codec h264 --bitrate 2000000 video.mp4 +ffmpeg -i input.mp4 -f mpegts - | \ + moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` See `moq-cli --help` for all options, or [FFmpeg documentation](/bin/cli). diff --git a/doc/lib/rs/crate/moq-mux.md b/doc/lib/rs/crate/moq-mux.md index 35ce70f90..501c724d1 100644 --- a/doc/lib/rs/crate/moq-mux.md +++ b/doc/lib/rs/crate/moq-mux.md @@ -96,11 +96,13 @@ For command-line importing, use [moq-cli](/bin/cli): # Install cargo install moq-cli -# Publish a video file -moq-cli publish video.mp4 +# Publish a video file (remux to MPEG-TS and pipe it in) +ffmpeg -i input.mp4 -c copy -f mpegts - | \ + moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts # Publish from FFmpeg -ffmpeg -i input.mp4 -f mpegts - | moq-cli publish - +ffmpeg -i input.mp4 -f mpegts - | \ + moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` ## Next Steps diff --git a/doc/lib/rs/crate/moq-token.md b/doc/lib/rs/crate/moq-token.md index f351dbc79..29230a936 100644 --- a/doc/lib/rs/crate/moq-token.md +++ b/doc/lib/rs/crate/moq-token.md @@ -51,7 +51,7 @@ nix build github:moq-dev/moq#moq-token-cli ```bash docker pull moqdev/moq-token-cli -docker run -v "$(pwd):/app" -w /app moqdev/moq-token-cli --key root.jwk generate +docker run -v "$(pwd):/app" -w /app moqdev/moq-token-cli generate --out root.jwk ``` Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub](https://hub.docker.com/r/moqdev/moq-token-cli). diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 2ea21fc7a..3d10f957a 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -122,11 +122,13 @@ cargo install moq-cli **Usage:** ```bash -# Publish a video file -moq-cli publish video.mp4 +# Publish a video file (remux to MPEG-TS and pipe it in) +ffmpeg -i input.mp4 -c copy -f mpegts - | \ + moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts # Publish from FFmpeg -ffmpeg -i input.mp4 -f mpegts - | moq-cli publish - +ffmpeg -i input.mp4 -f mpegts - | \ + moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts ``` [Learn more](/bin/cli) @@ -145,10 +147,10 @@ cargo install moq-token-cli ```bash # Generate a key -moq-token-cli --key root.jwk generate +moq-token-cli generate --out root.jwk # Sign a token -moq-token-cli --key root.jwk sign \ +moq-token-cli sign --key root.jwk \ --root "rooms/123" \ --publish "alice" \ --expires 1735689600 diff --git a/doc/lib/swift/moq.md b/doc/lib/swift/moq.md index 465b4ef45..f6d04c832 100644 --- a/doc/lib/swift/moq.md +++ b/doc/lib/swift/moq.md @@ -139,7 +139,7 @@ task.cancel() // releases native resources To run the test suite, build a host-only XCFramework first: ```bash -just check-ffi +just swift check ``` This runs `swift/scripts/check.sh`, which builds `moq-ffi` for the host arch, regenerates the UniFFI Swift bindings, drops a single-slice `MoqFFI.xcframework` into `swift/`, and runs `swift test` against the monolithic local-dev `Package.swift`. Requires macOS with `xcodebuild`. diff --git a/doc/package.json b/doc/package.json index b2f264f2e..d7292dc3e 100644 --- a/doc/package.json +++ b/doc/package.json @@ -11,6 +11,6 @@ }, "devDependencies": { "vitepress": "^1.6.4", - "wrangler": "^4.95.0" + "wrangler": "^4.99.0" } } diff --git a/doc/setup/dev.md b/doc/setup/dev.md index 84e7becd6..327e0ad4e 100644 --- a/doc/setup/dev.md +++ b/doc/setup/dev.md @@ -38,7 +38,7 @@ just fix just test # Publish a HLS broadcast (CMAF) over MoQ -just pub-hls tos +just pub hls tos ``` Want more? See the [justfile](https://github.com/moq-dev/moq/blob/main/justfile) for all commands. @@ -60,19 +60,19 @@ Anything you publish is public and discoverable... so be careful and don't abuse ```bash # Run the web server, pointing to the public relay # NOTE: The `bbb` demo on moq.dev uses a different path so it won't show up. -just web https://cdn.moq.dev/anon +just web serve https://cdn.moq.dev/anon # Publish Tears of Steel, watch it via https://moq.dev/watch?name=tos just pub tos https://cdn.moq.dev/anon # Publish a clock broadcast -just clock publish https://cdn.moq.dev/anon +just pub clock publish https://cdn.moq.dev/anon # Subscribe to said clock broadcast (different tab) -just clock subscribe https://cdn.moq.dev/anon +just pub clock subscribe https://cdn.moq.dev/anon # Publish an authentication broadcast -just pub av1 https://cdn.moq.dev/?jwt=not_a_real_token_ask_for_one +just pub bbb https://cdn.moq.dev/?jwt=not_a_real_token_ask_for_one ``` ## Debugging diff --git a/doc/setup/windows.md b/doc/setup/windows.md index 1d02d0338..950f68c9a 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/anon --broadcast clock --tls-fingerprint %FP% publish +cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --tls-fingerprint %FP% publish REM Subscribe (separate terminal) -cargo run -p moq-native --example clock -- --url https://localhost:4443/anon --broadcast clock --tls-fingerprint %FP% subscribe +cargo run -p moq-native --example clock -- --url https://localhost:4443 --broadcast clock --tls-fingerprint %FP% subscribe ``` The subscriber prints the current time once per second, sourced from the diff --git a/flake.nix b/flake.nix index 1537b4a30..48faa5bae 100644 --- a/flake.nix +++ b/flake.nix @@ -243,13 +243,18 @@ formatter = pkgs.nixfmt-tree; - # Heavy Rust CI (clippy / doc / test, all features) as crane - # derivations: deps compile once and cache, only first-party code - # recompiles per run, and there's no persistent CARGO_TARGET_DIR to - # bound. Run with `nix flake check` or a single one via - # `nix build .#checks..clippy`. The cheap, non-compiling lints - # (rustfmt, cargo-deny/shear/sort) stay in `just rs ci`. - checks = overlayPkgs.moqChecks; + # Heavy Rust CI (clippy / doc / test) runs as plain cargo via `just rs + # ci` (see rs/justfile), no longer through crane. `nix flake check` is + # kept -- it still validates flake eval + builds the dev shell -- but no + # longer compiles the workspace, so it's cheap. Release artifacts still + # build via crane `buildPackage` (see `packages` above / release-*.yml). + # + # On the self-hosted runner those cargo checks transparently reuse a + # per-crate compiler cache (rustc is wrapped by sccache via the runner + # environment), so a Cargo.lock change recompiles only the changed crate + # + its reverse-deps. That's a runner-side concern -- nothing here or in + # the workflows configures it. + checks = { }; } ); } diff --git a/js/clock/package.json b/js/clock/package.json index ef13a59d5..dec6fcc6a 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.1", + "@types/node": "^25.9.2", "typescript": "^6.0.3" } } diff --git a/js/moq-boy/package.json b/js/moq-boy/package.json index c8151378c..f04a8094b 100644 --- a/js/moq-boy/package.json +++ b/js/moq-boy/package.json @@ -36,7 +36,7 @@ "rimraf": "^6.1.3", "solid-js": "^1.9.13", "typescript": "^6.0.3", - "vite": "^8.0.14", + "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12" } } diff --git a/js/net/package.json b/js/net/package.json index 29cf2e9a1..c474c6d4d 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.1", + "@types/node": "^25.9.2", "@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 57fedfda5..917d31367 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -37,6 +37,6 @@ "esbuild": "^0.28.0", "rimraf": "^6.1.3", "typescript": "^6.0.3", - "vite": "^8.0.14" + "vite": "^8.0.16" } } diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 7f47799da..bed359255 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -28,6 +28,12 @@ export class Broadcast { // sections (e.g. `scte35`) by locking it too. readonly catalog = new CatalogProducer(); + // The underlying network broadcast, (re)created on each (re)connection and `undefined` while + // offline. Exposed so an application can serve its own tracks alongside the built-in + // catalog/audio/video, e.g. `net.createTrack("meta.json")` plus a matching `catalog` section. + // Reacquire it via an effect, since reconnecting swaps in a fresh producer. + readonly net = new Signal(undefined); + signals = new Effect(); constructor(props?: BroadcastProps) { @@ -72,6 +78,12 @@ export class Broadcast { const broadcast = new Moq.Broadcast(); effect.cleanup(() => broadcast.close()); + // Publish it before serving so an application reacting to `net` can insert its own tracks. + this.net.set(broadcast); + effect.cleanup(() => { + if (this.net.peek() === broadcast) this.net.set(undefined); + }); + connection.publish(name, broadcast); effect.spawn(this.#runBroadcast.bind(this, broadcast, effect)); diff --git a/js/signals/package.json b/js/signals/package.json index 7a30200e2..f69dea5d0 100644 --- a/js/signals/package.json +++ b/js/signals/package.json @@ -22,12 +22,12 @@ "typescript": "^6.0.3", "rimraf": "^6.1.3", "@types/bun": "^1.3.11", - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "react": "^19.0.0", "solid-js": "^1.9.13" }, "peerDependencies": { - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "react": "^19.0.0", "solid-js": "^1.9.13" }, diff --git a/js/token/package.json b/js/token/package.json index fd1d49aa7..f1436e86a 100644 --- a/js/token/package.json +++ b/js/token/package.json @@ -20,13 +20,13 @@ }, "dependencies": { "@hexagon/base64": "^2.0.4", - "commander": "^14.0.2", + "commander": "^15.0.0", "jose": "^6.2.3", "zod": "^4.4.3" }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/node": "^25.9.1", + "@types/node": "^25.9.2", "rimraf": "^6.1.3", "typescript": "^6.0.3" } diff --git a/js/watch/package.json b/js/watch/package.json index 7a6689744..af848230b 100644 --- a/js/watch/package.json +++ b/js/watch/package.json @@ -38,6 +38,6 @@ "esbuild": "^0.28.0", "rimraf": "^6.1.3", "typescript": "^6.0.3", - "vite": "^8.0.14" + "vite": "^8.0.16" } } diff --git a/justfile b/justfile index cdf422902..bdcb4eb8a 100644 --- a/justfile +++ b/justfile @@ -90,10 +90,20 @@ ci BASE="": just go ci "$files" fi + # Validate the flake (eval + dev shell build) via `nix flake check`. This no + # longer compiles the workspace -- the heavy Rust CI (clippy/doc/test) moved + # to `just rs ci` (plain cargo) and `checks` is unwired (see flake.nix) -- so + # it's cheap. Gate it to Nix/Rust input changes anyway: a pure doc/JS PR + # can't affect flake eval. Empty $files is a force-run, so run then. + if [[ -z "$files" ]] || echo "$files" | grep -qE '(^rs/|^Cargo\.(toml|lock)$|^flake\.lock$|\.nix$)'; then + nix flake check + else + echo "ci: no Nix/Rust inputs changed; skipping nix flake check." + fi + # Cheap; always run. `bun install` is needed for remark-cli, since # `just js ci` (where bun deps would otherwise install) is skipped # when the diff has no JS-scoped files. - nix flake check bun install --frozen-lockfile bun remark . --quiet --frail shfmt --diff $(shfmt -f . | grep -v '\.direnv/') diff --git a/nix/overlay.nix b/nix/overlay.nix index 077a01b8f..30b79b123 100644 --- a/nix/overlay.nix +++ b/nix/overlay.nix @@ -201,124 +201,15 @@ let ''; }; - # --- CI checks (run via `nix flake check`) ------------------------------- - # - # The heavy Rust CI (clippy / doc / test) used to run as plain `cargo` on the - # self-hosted runner, compiling into a persistent CARGO_TARGET_DIR that grew - # unbounded (every branch's artifacts, never pruned) and was unsafe to share - # across parallel jobs. Expressing those checks as crane derivations instead - # makes Nix the cache: dependencies compile exactly once (`checkDeps`), land - # in the binary cache, are shared across machines, and are LRU-evicted by the - # store's free-space GC. Only first-party code recompiles, inside Nix's - # sandbox (which is wiped per build), so there is no target dir to bound. - - # crane's cleanCargoSource keeps only Rust-relevant files, but the test - # targets compiled under `--all-targets` `include_str!`/`include_bytes!` data - # fixtures (rs/moq-mux test_data/*.ts, rs/moq-json tests/vectors.json, etc.) - # and libmoq's build.rs reads moq.pc.in. Rather than enumerate every asset - # (fragile -- a new fixture would break CI), take the whole tree minus the - # heavy non-source dirs. Dep caching is unaffected (buildDepsOnly keys on - # Cargo.lock); only the first-party check rebuilds on a non-Rust change. - checkSrc = final.lib.cleanSourceWith { - src = ../.; - name = "source"; - filter = - path: _type: - let - # Prune a dir both as an entry ("…/target") and its contents - # ("…/target/…") so we don't walk node_modules etc. at all. - excluded = name: final.lib.hasSuffix "/${name}" path || final.lib.hasInfix "/${name}/" path; - in - !( - excluded "node_modules" - || excluded "target" - || excluded ".direnv" - || excluded ".venv" - || excluded ".git" - ); - }; - - # Every system lib the workspace needs to compile with all features on: - # bindgen (clang) for ffmpeg/vaapi, GStreamer for moq-gst, ALSA for - # moq-audio's capture feature, libva for moq-video's VAAPI backend. The - # Linux-only libs match the devShell's `lib.optionals (!isDarwin)` set. - checkCommonArgs = { - src = checkSrc; - # The workspace root Cargo.toml has no [package], so name it explicitly - # (otherwise crane warns and uses a placeholder). - pname = "moq-workspace"; - version = "0.0.0"; - strictDeps = true; - nativeBuildInputs = with final; [ - pkg-config - clang - cmake - # boring-sys (quiche, under --all-features) shells out to `git` to apply - # its BoringSSL patches; the devShell has it on $PATH, the sandbox doesn't. - git - ]; - buildInputs = - (with final; [ - ffmpeg - glib - curl - gst_all_1.gstreamer - gst_all_1.gst-plugins-base - ]) - ++ final.lib.optionals final.stdenv.isLinux ( - with final; - [ - alsa-lib - libva - ] - ); - LIBCLANG_PATH = "${final.libclang.lib}/lib"; - # jemalloc (pulled in by --all-features) builds -O0 configure tests that - # clash with Nix's _FORTIFY_SOURCE hardening (needs -O). Same as the - # package builds and the devShell. - hardeningDisable = [ "fortify" ]; - # Scope the dep build to the whole workspace with every feature so a single - # checkDeps covers clippy/doc/test below. - cargoExtraArgs = "--workspace --all-features"; - }; - - # Compile all third-party deps once; the checks reuse this artifact. - checkDeps = craneLib.buildDepsOnly (checkCommonArgs // { pname = "moq-workspace-deps"; }); - - moqChecks = { - clippy = craneLib.cargoClippy ( - checkCommonArgs - // { - cargoArtifacts = checkDeps; - # --workspace/--all-features come from checkCommonArgs.cargoExtraArgs, - # which crane prepends; only add the clippy-specific flags here. - cargoClippyExtraArgs = "--all-targets -- -D warnings"; - } - ); - - doc = craneLib.cargoDoc ( - checkCommonArgs - // { - cargoArtifacts = checkDeps; - # --all-targets is invalid for `cargo doc`, so it stays out of the - # shared cargoExtraArgs; --workspace/--all-features are inherited. - cargoDocExtraArgs = "--no-deps"; - RUSTDOCFLAGS = "-D warnings"; - } - ); - - test = craneLib.cargoTest ( - checkCommonArgs - // { - cargoArtifacts = checkDeps; - cargoTestExtraArgs = "--all-targets"; - } - ); - }; + # CI checks (clippy / doc / test) run as plain cargo via `just rs ci`, not + # through crane/`nix flake check`. The self-hosted runner caches compilation + # per-crate with sccache (wired into the runner environment, not here), so a + # Cargo.lock change recompiles only the changed crate + its reverse-deps. + # ./target stays ephemeral (wiped per job) -- the persistent CARGO_TARGET_DIR + # growth that the old crane checks were introduced to fix doesn't recur. + # Release artifacts still build via crane `buildPackage` below. in { - inherit moqChecks; - moq-relay = craneLib.buildPackage moqRelayArgs; moq-relay-x86_64-apple-darwin = craneLib.buildPackage (crossX86Darwin moqRelayArgs); diff --git a/package.json b/package.json index d2881daa7..1f85e6710 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "moq", "version": "0.0.0", "devDependencies": { - "@biomejs/biome": "^2.4.15", - "concurrently": "^9.2.1", + "@biomejs/biome": "^2.4.16", + "concurrently": "^10.0.3", "publint": "^0.3.21", "remark-cli": "^12.0.1", "remark-frontmatter": "^5.0.0", diff --git a/rs/justfile b/rs/justfile index 1a5b8b5e4..a18edddc7 100644 --- a/rs/justfile +++ b/rs/justfile @@ -35,21 +35,26 @@ ci FILES="": echo "rs: no Rust changes; skipping." exit 0 fi - # Heavy compiles -- clippy, doc, and the --all-features test run -- are crane - # derivations built by `nix flake check` (see flake.nix `checks` and - # nix/overlay.nix): deps cache in /nix/store, only first-party code rebuilds - # in the sandbox, and there's no persistent cargo target dir to bound. What - # stays here is non-compiling hygiene, cargo-deny (hits the network, so it - # can't run in the Nix sandbox), and the default / no-default-feature - # `cargo check`s the all-features crane run doesn't cover -- those compile - # into the in-workspace ./target, which the runner wipes per job. - # (Local devs still get the full cargo loop via `just rs check`.) + # Hygiene (non-compiling) + cargo-deny (hits the network, so it couldn't run + # in the old Nix sandbox; runs fine here). cargo fmt --all --check cargo sort --workspace --check --no-format cargo shear + cargo deny check --show-stats + + # Compiles. The all-features clippy / doc / test were crane derivations built + # by `nix flake check`; they now run here as plain cargo (flake.nix `checks` + # is unwired). The default / no-default-feature `cargo check`s cover feature + # edges the all-features run doesn't. Everything builds into the in-workspace + # ./target, which the runner wipes per job; on the self-hosted runner the + # heavy compilation is transparently cached per-crate by sccache (wired into + # the runner environment, not here), so there's no persistent target dir to + # bound. (Local devs still get the full cargo loop via `just rs check`.) cargo check --workspace --all-targets cargo check --workspace --no-default-features - cargo deny check --show-stats + cargo clippy --workspace --all-targets --all-features -- -D warnings + RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps + cargo test --workspace --all-targets --all-features # Auto-fix clippy/format/shear/sort. fix: diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 2db24c285..e6fa8475d 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -19,7 +19,7 @@ websocket = ["moq-native/websocket"] [dependencies] anyhow = { version = "1", features = ["backtrace"] } -boytacean = { version = "0.11", features = ["gen-mock"] } +boytacean = { version = "0.12", features = ["gen-mock"] } bytes = "1" clap = { version = "4", features = ["derive"] } hang = { workspace = true } diff --git a/rs/moq-boy/src/audio.rs b/rs/moq-boy/src/audio.rs index 9d3cd5ffe..51b643277 100644 --- a/rs/moq-boy/src/audio.rs +++ b/rs/moq-boy/src/audio.rs @@ -1,4 +1,4 @@ -//! Audio: stereo unsigned-8-bit PCM (Game Boy APU) -> Opus -> MoQ. +//! Audio: stereo signed-16-bit PCM (Game Boy APU) -> Opus -> MoQ. //! //! A thin wrapper over [`moq_audio::AudioProducer`], which resamples to 48 kHz, //! encodes Opus, and anchors timestamps to a wall clock so audio stays in sync @@ -26,7 +26,7 @@ impl AudioEncoder { input_sample_rate: u32, ) -> Result { let input = moq_audio::EncoderInput { - format: moq_audio::AudioFormat::U8, + format: moq_audio::AudioFormat::S16, sample_rate: input_sample_rate, channels: CHANNELS, }; @@ -48,12 +48,16 @@ impl AudioEncoder { self.producer.reset_epoch(); } - /// Push interleaved unsigned-8-bit stereo PCM captured at `elapsed` (since + /// Push interleaved signed-16-bit stereo PCM captured at `elapsed` (since /// the emulator started, shared with the video clock). - pub fn push_samples(&mut self, samples: &[u8], elapsed: Duration) -> Result<()> { + pub fn push_samples(&mut self, samples: &[i16], elapsed: Duration) -> Result<()> { + let mut data = Vec::with_capacity(samples.len() * 2); + for sample in samples { + data.extend_from_slice(&sample.to_le_bytes()); + } let frame = moq_audio::Frame { timestamp_us: elapsed.as_micros() as u64, - data: Bytes::copy_from_slice(samples), + data: Bytes::from(data), }; self.producer.write(&frame)?; Ok(()) diff --git a/rs/moq-boy/src/emulator.rs b/rs/moq-boy/src/emulator.rs index 33a95c124..260e05eac 100644 --- a/rs/moq-boy/src/emulator.rs +++ b/rs/moq-boy/src/emulator.rs @@ -124,9 +124,9 @@ impl Emulator { rgba } - /// Drain accumulated audio samples from the APU. - pub fn audio_samples(&mut self) -> Vec { - let samples: Vec = self.gb.audio_buffer().iter().copied().collect(); + /// Drain accumulated 16-bit PCM audio samples from the APU. + pub fn audio_samples(&mut self) -> Vec { + let samples: Vec = self.gb.audio_buffer().iter().copied().collect(); self.gb.clear_audio_buffer(); samples } diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 7102a1cf3..1b6934123 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.7.33" +version = "0.7.34" edition = "2024" rust-version.workspace = true @@ -48,3 +48,8 @@ url = "2" [target.'cfg(unix)'.dependencies] sd-notify = "0.5" + +[dev-dependencies] +# `test-util` enables `tokio::time::pause` so the TS round-trip test simulates the +# exporter drain timeouts instantly instead of waiting on the wall clock. +tokio = { workspace = true, features = ["test-util"] } diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index c01f0bfaa..e72e72cda 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -106,11 +106,13 @@ pub struct CaptureArgs { enum PublishDecoder { Avc3 { - split: moq_mux::codec::h264::Split, + split: Box, import: Box, }, Fmp4(Box), - Ts(Box), + // TS carries undecoded elementary streams (SCTE-35, teletext, DVB AC-3, ...) + // verbatim, so it uses the `mpegts` catalog extension rather than the media-only `()`. + Ts(Box>), Flv(Box), Hls(Box), } @@ -168,13 +170,29 @@ pub struct Publish { impl Publish { pub fn new(format: &PublishFormat) -> anyhow::Result { let mut broadcast = moq_net::BroadcastInfo::new().produce(); - let catalog = moq_mux::catalog::Producer::new(&mut broadcast)?; + // TS carries undecoded elementary streams (SCTE-35, teletext, DVB AC-3, ...) + // verbatim, so it uses the `mpegts` catalog extension rather than the media-only + // `()`. The catalog producer owns the broadcast's catalog tracks, so each broadcast + // gets exactly one; TS builds its `Ext` catalog here instead of the shared `()` below. + if let PublishFormat::Ts = format { + let catalog = moq_mux::catalog::Producer::with_catalog( + &mut broadcast, + moq_mux::catalog::hang::Catalog::::default(), + )?; + let ts = ts::Import::new(broadcast.clone(), catalog); + return Ok(Self { + source: Source::Stream(PublishDecoder::Ts(Box::new(ts))), + broadcast, + }); + } + + let catalog = moq_mux::catalog::Producer::new(&mut broadcast)?; let source = match format { PublishFormat::Avc3 => { let track = moq_mux::import::unique_track(&mut broadcast, ".avc3")?; let import = moq_mux::codec::h264::Import::new(track, catalog.clone()); - let split = moq_mux::codec::h264::Split::new(); + let split = Box::new(moq_mux::codec::h264::Split::new()); Source::Stream(PublishDecoder::Avc3 { split, import: Box::new(import), @@ -184,10 +202,7 @@ impl Publish { let fmp4 = fmp4::Import::new(broadcast.clone(), catalog.clone()); Source::Stream(PublishDecoder::Fmp4(Box::new(fmp4))) } - PublishFormat::Ts => { - let ts = ts::Import::new(broadcast.clone(), catalog.clone()); - Source::Stream(PublishDecoder::Ts(Box::new(ts))) - } + PublishFormat::Ts => unreachable!("TS is handled above with the mpegts catalog extension"), PublishFormat::Flv => { let flv = flv::Import::new(broadcast.clone(), catalog.clone()); Source::Stream(PublishDecoder::Flv(Box::new(flv))) @@ -318,3 +333,225 @@ impl CaptureArgs { } } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use bytes::BytesMut; + use moq_mux::catalog::CatalogFormat; + use moq_mux::catalog::hang::{Catalog, Container}; + use moq_mux::container::ts::{Export, Import, catalog as tscat}; + use moq_mux::container::{Consumer, Frame, Producer}; + use moq_net::Timestamp; + + use super::*; + + /// Real H.264 + AAC TS, reused to give the manufactured input a video clock + /// (section-framed verbatim export requires one) and decodable media tracks. + const BBB: &[u8] = include_bytes!("../../moq-mux/src/container/ts/test_data/bbb.ts"); + + /// A libklvanc public-sample SCTE-35 splice_info_section (table_id 0xFC), carried + /// on a section-framed PID. Same bytes the moq-mux export round-trip test uses. + const CUE: &[u8] = &[ + 0xfc, 0x30, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xf0, 0x0a, 0x05, 0x00, 0x00, 0x2b, 0xb4, + 0x7f, 0xdf, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xad, 0x25, 0xe8, 0x39, + ]; + + /// Payload of an undecoded PES-framed stream (e.g. teletext/DVB AC-3 private data), + /// carried verbatim on its own PID with the original PES stream_id. + const PES_PAYLOAD: &[u8] = &[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; + + const SECTION_PID: u16 = 0x102; + const VERBATIM_PES_PID: u16 = 0x104; + const VERBATIM_PES_STREAM_ID: u8 = 0xC0; + + /// Drain an exporter, concatenating every frame's payload until output stops. The + /// producers stay alive (retained tracks), so the stream never hard-ends; pull until a + /// `next()` blocks, surfaced here as a timeout once the buffered frames are gone. + async fn drain(mut exporter: Export) -> Vec { + let mut out = Vec::new(); + while let Ok(res) = tokio::time::timeout(Duration::from_millis(500), exporter.next()).await { + match res.expect("exporter error") { + Some(frame) => out.extend_from_slice(&frame.payload), + None => break, + } + } + out + } + + /// Manufacture a TS feed carrying real video/audio plus one section-framed + /// verbatim stream (SCTE-35) and one PES-framed verbatim stream, by importing + /// `bbb.ts` into a broadcast that also holds the two ancillary tracks and + /// re-exporting with the `mpegts` catalog extension. + async fn manufacture_input() -> Vec { + let mut broadcast = moq_net::BroadcastInfo::new().produce(); + let consumer = broadcast.consume(); + let mut catalog = + moq_mux::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); + + // Section-framed verbatim stream (SCTE-35, stream_type 0x86). + let section = broadcast + .unique_track( + ".scte35", + moq_net::TrackInfo::default().with_timescale(hang::container::TIMESCALE), + ) + .unwrap(); + let mut section_track = tscat::Track::new(SECTION_PID); + section_track.verbatim = Some(tscat::Verbatim::new(0x86, tscat::Framing::Section)); + catalog + .lock() + .mpegts + .tracks + .insert(section.name().to_string(), section_track); + let mut section_producer = Producer::new(section, Container::Legacy); + section_producer + .write(Frame { + timestamp: Timestamp::from_millis(40).unwrap(), + duration: None, + payload: bytes::Bytes::from_static(CUE), + keyframe: true, + }) + .unwrap(); + section_producer.finish_group().unwrap(); + section_producer.finish().unwrap(); + + // PES-framed verbatim stream (undecoded private data, stream_type 0x06), with + // an explicit PES stream_id to round-trip. + let pes = broadcast + .unique_track( + ".data", + moq_net::TrackInfo::default().with_timescale(hang::container::TIMESCALE), + ) + .unwrap(); + let mut verbatim = tscat::Verbatim::new(0x06, tscat::Framing::Pes); + verbatim.stream_id = Some(VERBATIM_PES_STREAM_ID); + let mut pes_track = tscat::Track::new(VERBATIM_PES_PID); + pes_track.verbatim = Some(verbatim); + catalog.lock().mpegts.tracks.insert(pes.name().to_string(), pes_track); + let mut pes_producer = Producer::new(pes, Container::Legacy); + pes_producer + .write(Frame { + timestamp: Timestamp::from_millis(40).unwrap(), + duration: None, + payload: bytes::Bytes::from_static(PES_PAYLOAD), + keyframe: true, + }) + .unwrap(); + pes_producer.finish_group().unwrap(); + pes_producer.finish().unwrap(); + + // Add the real video/audio (moves `broadcast` into the importer). + let mut import = Import::new(broadcast, catalog.clone()); + import.decode(&BytesMut::from(BBB)).unwrap(); + import.finish().unwrap(); + + // `catalog`, the producers, and `import` stay alive: the exporter subscribes to + // the retained tracks. + drain( + Export::with_ts(consumer, CatalogFormat::Hang) + .await + .unwrap() + .with_latency(Duration::ZERO), + ) + .await + } + + /// Full CLI round-trip: a TS feed with undecoded streams goes through `Publish` + /// (which selects the `mpegts` catalog) and the subscribe-side `Export::with_ts`, + /// and the SCTE-35 section and the verbatim PES survive with their PIDs, framing, + /// PES stream_id, and byte-exact payloads. + #[tokio::test(start_paused = true)] + async fn ts_verbatim_streams_round_trip_through_cli() { + // Paused time auto-advances when the exporter parks, so the `drain` timeouts + // fire instantly instead of waiting on the wall clock. + let input = manufacture_input().await; + + // Publish side: `Publish::new(Ts)` builds a `ts::Import`, so the verbatim + // streams land in the broadcast instead of being dropped by the media-only path. + let mut publish = Publish::new(&PublishFormat::Ts).unwrap(); + let consumer = publish.consume(); + #[allow(irrefutable_let_patterns)] + let Source::Stream(decoder) = &mut publish.source else { + panic!("expected a stream source"); + }; + decoder.decode_buf(&input).unwrap(); + let PublishDecoder::Ts(import) = decoder else { + panic!("expected a TS decoder"); + }; + import.finish().unwrap(); + + // Subscribe side: the same `with_ts` call `run_ts` makes, re-emitting the + // ancillary streams verbatim. + let output = drain( + Export::with_ts(consumer, CatalogFormat::Hang) + .await + .unwrap() + .with_latency(Duration::ZERO), + ) + .await; + + // Re-import the round-tripped TS and inspect the recovered `mpegts` section. + let mut broadcast = moq_net::BroadcastInfo::new().produce(); + let consumer = broadcast.consume(); + let catalog = + moq_mux::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); + let mut import = Import::new(broadcast, catalog.clone()); + import.decode(&BytesMut::from(&output[..])).unwrap(); + import.finish().unwrap(); + let snapshot = catalog.snapshot(); + + let (section_name, section) = snapshot + .mpegts + .tracks + .iter() + .find(|(_, t)| t.verbatim.as_ref().is_some_and(|v| v.stream_type == 0x86)) + .expect("SCTE-35 section survived the round-trip"); + assert_eq!(section.pid, SECTION_PID, "section PID preserved"); + assert_eq!( + section.verbatim.as_ref().unwrap().framing, + tscat::Framing::Section, + "section framing preserved" + ); + let section_name = section_name.clone(); + + let (pes_name, pes) = snapshot + .mpegts + .tracks + .iter() + .find(|(_, t)| t.verbatim.as_ref().is_some_and(|v| v.stream_type == 0x06)) + .expect("verbatim PES survived the round-trip"); + assert_eq!(pes.pid, VERBATIM_PES_PID, "verbatim PES PID preserved"); + let pes_verbatim = pes.verbatim.as_ref().unwrap(); + assert_eq!(pes_verbatim.framing, tscat::Framing::Pes, "PES framing preserved"); + assert_eq!( + pes_verbatim.stream_id, + Some(VERBATIM_PES_STREAM_ID), + "PES stream_id preserved" + ); + let pes_name = pes_name.clone(); + + assert_eq!( + read_frame(&consumer, §ion_name).await, + CUE, + "SCTE-35 section round-trips byte-for-byte" + ); + assert_eq!( + read_frame(&consumer, &pes_name).await, + PES_PAYLOAD, + "verbatim PES payload round-trips byte-for-byte" + ); + } + + /// Read the first frame of a verbatim track back as raw bytes. + async fn read_frame(consumer: &moq_net::BroadcastConsumer, name: &str) -> Vec { + let track = consumer.track(name).unwrap().subscribe(None).unwrap().await.unwrap(); + let mut reader = Consumer::new(track, Container::Legacy).with_latency(Duration::ZERO); + let frame = tokio::time::timeout(Duration::from_secs(1), reader.read()) + .await + .expect("verbatim read timed out") + .unwrap() + .expect("a published verbatim frame"); + frame.payload.to_vec() + } +} diff --git a/rs/moq-cli/src/subscribe.rs b/rs/moq-cli/src/subscribe.rs index f4f4449cc..be494eeaa 100644 --- a/rs/moq-cli/src/subscribe.rs +++ b/rs/moq-cli/src/subscribe.rs @@ -321,8 +321,10 @@ impl Subscribe { // TS emits PAT/PMT then a continuous PES stream (re-emitting PAT/PMT at // keyframes for tune-in). Avc3/Hev1 sources pass through as Annex-B; AAC - // is re-framed as ADTS. `fragment_duration` does not apply to TS. - let mut ts = moq_mux::container::ts::Export::with_catalog_format(self.broadcast, self.catalog) + // is re-framed as ADTS. `fragment_duration` does not apply to TS. `with_ts` + // selects the `mpegts` catalog extension so undecoded elementary streams + // (SCTE-35, teletext, DVB AC-3, ...) are re-emitted verbatim on their PIDs. + let mut ts = moq_mux::container::ts::Export::with_ts(self.broadcast, self.catalog) .await? .with_latency(self.args.max_latency); diff --git a/rs/moq-gst/README.md b/rs/moq-gst/README.md index 617f06140..1ffb4eb3f 100644 --- a/rs/moq-gst/README.md +++ b/rs/moq-gst/README.md @@ -40,9 +40,13 @@ The `moq-gst` flake output bundles the plugin with wrappers around `gst-inspect- nix shell github:moq-dev/moq#moq-gst --command gst-inspect-1.0 moq # Subscribe to the always-on public test broadcast and render to a window. +# moqsrc emits one pad per rendition (video_0, audio_0, ...); link the one(s) you +# want by name. A bare `moqsrc ! decodebin3 ! ...` only links the first pad offered, +# which on a video+audio broadcast may be the audio pad (so a video sink shows nothing). nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -v -e \ - moqsrc url=https://cdn.moq.dev/demo broadcast=bbb.hang \ - ! decodebin3 ! videoconvert ! autovideosink + moqsrc name=s url=https://cdn.moq.dev/demo broadcast=bbb.hang \ + s.video_0 ! queue ! decodebin3 ! videoconvert ! autovideosink \ + s.audio_0 ! queue ! decodebin3 ! audioconvert ! autoaudiosink # Publish your own broadcast on the public anon relay (then sub to it from anywhere). curl -fsSL https://vid.moq.dev/bbb.mp4 -o bbb.mp4 diff --git a/rs/moq-gst/src/lib.rs b/rs/moq-gst/src/lib.rs index 69bff0f10..1b779d7ac 100644 --- a/rs/moq-gst/src/lib.rs +++ b/rs/moq-gst/src/lib.rs @@ -32,7 +32,9 @@ gst::plugin_define!( env!("CARGO_PKG_DESCRIPTION"), plugin_init, concat!(env!("CARGO_PKG_VERSION"), "-", env!("COMMIT_ID")), - "Apache 2.0", + // GStreamer only loads plugins whose license string is in its recognised set. "Apache 2.0" is not, + // so the registry silently refuses the plugin. The crate is MIT OR Apache-2.0, so declare the MIT side. + "MIT/X11", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_NAME"), env!("CARGO_PKG_REPOSITORY"), diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index 82c8b01da..a63b7afb8 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -82,7 +82,8 @@ impl SessionHandle { struct PadState { decoder: moq_mux::import::Track, - reference_pts: Option, + // The pad's most recent TIME segment, used to map a buffer PTS to GStreamer running time. + segment: Option>, } struct RuntimeState { @@ -99,6 +100,10 @@ enum ControlMessage { pad_name: String, caps: gst::Caps, }, + Segment { + pad_name: String, + segment: gst::Segment, + }, Buffer { pad_name: String, data: Bytes, @@ -352,6 +357,29 @@ impl MoqSink { gst::Pad::event_default(pad, Some(&*self.obj()), event) } + gst::EventView::Segment(segment) => { + let Some(sender) = self + .session + .lock() + .unwrap() + .as_ref() + .map(|handle| handle.sender.clone()) + else { + return false; + }; + + if sender + .send(ControlMessage::Segment { + pad_name: pad.name().to_string(), + segment: segment.segment().to_owned(), + }) + .is_err() + { + return false; + } + + gst::Pad::event_default(pad, Some(&*self.obj()), event) + } gst::EventView::Eos(_) => { let Some(sender) = self .session @@ -418,6 +446,11 @@ async fn run_session( gst::error!(CAT, "failed to configure pad: {err:#}"); } } + ControlMessage::Segment { pad_name, segment } => { + if let Err(err) = handle_segment(&mut runtime, pad_name, segment) { + gst::error!(CAT, "failed to set segment: {err:#}"); + } + } ControlMessage::Buffer { pad_name, data, pts } => { if let Err(err) = handle_buffer(&mut runtime, pad_name, data, pts) { gst::error!(CAT, "failed to publish buffer: {err:#}"); @@ -492,13 +525,15 @@ fn handle_caps(runtime: &mut RuntimeState, pad_name: String, caps: gst::Caps) -> other => anyhow::bail!("unsupported caps: {}", other), }; - runtime.pads.insert( - pad_name, - PadState { - decoder, - reference_pts: None, - }, - ); + runtime.pads.insert(pad_name, PadState { decoder, segment: None }); + Ok(()) +} + +fn handle_segment(runtime: &mut RuntimeState, pad_name: String, segment: gst::Segment) -> Result<()> { + let pad = runtime.pads.get_mut(&pad_name).context("pad not configured")?; + // Only TIME segments map to a media timeline; a non-TIME (e.g. BYTES) segment leaves it unset so + // the buffer path falls back to the raw PTS. + pad.segment = segment.downcast::().ok(); Ok(()) } @@ -512,10 +547,16 @@ fn new_decoder(runtime: &mut RuntimeState, format: &str, buf: &[u8]) -> Result) -> Result<()> { let pad = runtime.pads.get_mut(&pad_name).context("pad not configured")?; + // Emit the GStreamer running time, which is the broadcast-aligned timeline shared by every pad in + // the pipeline. Rebasing each pad to its own first PTS (the previous behavior) would zero them + // independently and break A/V alignment. Fall back to the raw PTS only if no TIME segment is known. let ts = pts.and_then(|pts| { - let reference = *pad.reference_pts.get_or_insert(pts); - let relative = pts.checked_sub(reference)?; - hang::container::Timestamp::from_micros(relative.nseconds() / 1000).ok() + let timeline = pad + .segment + .as_ref() + .and_then(|segment| segment.to_running_time(pts)) + .unwrap_or(pts); + hang::container::Timestamp::from_micros(timeline.nseconds() / 1000).ok() }); pad.decoder.decode(&data, ts).map_err(|e| anyhow::anyhow!(e)) diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 466d30344..77a466be1 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -21,9 +21,16 @@ static RUNTIME: LazyLock = LazyLock::new(|| { .expect("spawn tokio runtime") }); -/// Process-wide pad id counter. Kept global (not per-session) so a pad created by a -/// restarted session can't collide with one still being torn down by the previous one. -static NEXT_PAD_ID: AtomicU64 = AtomicU64::new(0); +/// Process-wide pad id counters, one per pad kind. Kept global (not per-session) so a pad +/// created by a restarted session can't collide with one still being torn down by the +/// previous one, and split per kind so the *first* video pad is reliably `video_0` and the +/// first audio pad `audio_0`. That predictability matters because `gst-launch` links a +/// source's sometimes-pads by name (`moqsrc name=s s.video_0 ! ...`); a single shared counter +/// made the first pad's number depend on catalog arrival order (audio could claim `0`), +/// silently breaking those pipelines. Counters only ever increment, so a mid-stream reshape +/// still gets a fresh, collision-free id. +static NEXT_VIDEO_PAD_ID: AtomicU64 = AtomicU64::new(0); +static NEXT_AUDIO_PAD_ID: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone, Default)] struct Settings { @@ -414,7 +421,11 @@ async fn reconcile( } }; - let id = NEXT_PAD_ID.fetch_add(1, Ordering::Relaxed); + let id = match d.kind { + TrackKind::Video => &NEXT_VIDEO_PAD_ID, + TrackKind::Audio => &NEXT_AUDIO_PAD_ID, + } + .fetch_add(1, Ordering::Relaxed); let track_subscriber = broadcast.track(&name)?.subscribe(None)?.await?; let track = moq_mux::container::Consumer::new(track_subscriber, container).with_latency(Duration::from_secs(1)); @@ -466,9 +477,11 @@ fn plan_reconcile(desired: &HashMap, active: &HashMap` / `audio_` from a -/// process-unique counter (matching the `%u` templates) rather than after the -/// track name, so a rendition can be torn down and recreated (when its -/// codec/resolution changes mid-stream) without two pads ever sharing a name. +/// per-kind, process-unique counter (matching the `%u` templates) rather than after +/// the track name, so a rendition can be torn down and recreated (when its +/// codec/resolution changes mid-stream) without two pads ever sharing a name. The +/// first pad of each kind is `video_0` / `audio_0`, so `gst-launch` can link them by +/// name regardless of which rendition the catalog announces first. struct TrackDescriptor { kind: TrackKind, name: String, diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index baff448c6..6ccd58405 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -34,6 +34,7 @@ num_enum = "0.7" scuffle-av1 = { version = "0.1.4" } scuffle-h265 = { version = "0.2.2" } serde = { workspace = true } +serde_with = { version = "3", features = ["base64"] } thiserror = "2" tokio = { workspace = true, features = ["macros"] } tracing = "0.1" diff --git a/rs/moq-mux/src/codec/aac/import.rs b/rs/moq-mux/src/codec/aac/import.rs index f9e8ec002..35c4b25d0 100644 --- a/rs/moq-mux/src/codec/aac/import.rs +++ b/rs/moq-mux/src/codec/aac/import.rs @@ -42,6 +42,11 @@ impl Import { }) } + /// 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() diff --git a/rs/moq-mux/src/codec/annexb.rs b/rs/moq-mux/src/codec/annexb.rs index 4f5491470..33b2041c3 100644 --- a/rs/moq-mux/src/codec/annexb.rs +++ b/rs/moq-mux/src/codec/annexb.rs @@ -70,6 +70,42 @@ pub fn build_prefix<'a, I: IntoIterator>(nals: I) -> Bytes { out.freeze() } +/// Append `nal` to `set` unless a byte-identical entry is already present, +/// preserving insertion order. Returns true if it was added. +/// +/// Used to accumulate the distinct parameter-set NALs (SPS/PPS, plus VPS for +/// H.265) a stream carries: avcC/hvcC hold an ordered list, and a source may +/// define several (e.g. two PPS) that slices reference by id. +pub(crate) fn push_distinct(set: &mut Vec, nal: &Bytes) -> bool { + if set.iter().any(|existing| existing == nal) { + return false; + } + set.push(nal.clone()); + true +} + +/// Reconcile the retained parameter sets with what a keyframe access unit carried +/// inline, called when the keyframe slice is reached: +/// +/// - If the AU presented its own set (`seen` non-empty), adopt it as the retained +/// set, dropping any the new GOP no longer uses (a mid-stream reinit). +/// - If the AU carried none, re-inject the retained set into `chunks` as Annex-B +/// so a receiver tuning in at this keyframe still gets them. +/// +/// `seen` is this AU's inline NALs (already appended to `chunks`); `retained` is +/// the cross-GOP set re-injected on bare keyframes. +pub(crate) fn reconcile_keyframe_params(chunks: &mut BytesMut, retained: &mut Vec, seen: &mut Vec) { + if seen.is_empty() { + for nal in retained.iter() { + chunks.extend_from_slice(&START_CODE); + chunks.extend_from_slice(nal); + } + seen.clone_from(retained); + } else if seen != retained { + retained.clone_from(seen); + } +} + pub struct NalIterator<'a, T: Buf + AsRef<[u8]> + 'a> { buf: &'a mut T, start: Option, diff --git a/rs/moq-mux/src/codec/h264/export.rs b/rs/moq-mux/src/codec/h264/export.rs index 6f6d4dcc8..4bb496882 100644 --- a/rs/moq-mux/src/codec/h264/export.rs +++ b/rs/moq-mux/src/codec/h264/export.rs @@ -234,7 +234,7 @@ mod tests { /// 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() + 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 diff --git a/rs/moq-mux/src/codec/h264/import.rs b/rs/moq-mux/src/codec/h264/import.rs index 4d694e815..f36d1973c 100644 --- a/rs/moq-mux/src/codec/h264/import.rs +++ b/rs/moq-mux/src/codec/h264/import.rs @@ -108,6 +108,11 @@ impl Import { 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() diff --git a/rs/moq-mux/src/codec/h264/mod.rs b/rs/moq-mux/src/codec/h264/mod.rs index c7e94fc2a..de3cd1e6f 100644 --- a/rs/moq-mux/src/codec/h264/mod.rs +++ b/rs/moq-mux/src/codec/h264/mod.rs @@ -92,6 +92,15 @@ pub enum Error { #[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, @@ -212,35 +221,55 @@ fn pack_constraint_flags(sps: &h264_parser::Sps) -> u8 { | ((sps.constraint_set5_flag as u8) << 2) } -/// Build an AVCDecoderConfigurationRecord (ISO/IEC 14496-15 §5.3.3.1.2) from a -/// single SPS and PPS NAL. -pub(crate) fn build_avcc(sps_nal: &[u8], pps_nal: &[u8]) -> Result { - if sps_nal.len() > u16::MAX as usize { - return Err(Error::SpsTooLarge(sps_nal.len())); +/// Build an AVCDecoderConfigurationRecord (ISO/IEC 14496-15 §5.3.3.1.2) from the +/// given SPS and PPS NALs. At least one SPS is required; the profile/level fields +/// 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]) -> Result { + let first_sps = sps_nals.first().ok_or(Error::MissingSps)?; + if first_sps.len() < 4 { + return Err(Error::SpsTooShort); } - if pps_nal.len() > u16::MAX as usize { - return Err(Error::PpsTooLarge(pps_nal.len())); + // numOfSequenceParameterSets is a 5-bit field, numOfPictureParameterSets a byte. + if sps_nals.len() > 0x1f { + return Err(Error::TooManySps(sps_nals.len())); } - if sps_nal.len() < 4 { - return Err(Error::SpsTooShort); + 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 = sps_nal[1]; - let constraints = sps_nal[2]; - let level_idc = sps_nal[3]; + let profile_idc = first_sps[1]; + let constraints = first_sps[2]; + let level_idc = first_sps[3]; - let mut out = BytesMut::with_capacity(11 + sps_nal.len() + pps_nal.len()); + let payload: usize = sps_nals.iter().chain(pps_nals).map(|n| 2 + n.len()).sum(); + let mut out = BytesMut::with_capacity(7 + payload); out.put_u8(1); // configurationVersion out.put_u8(profile_idc); out.put_u8(constraints); out.put_u8(level_idc); out.put_u8(0xff); // reserved (6 bits) | lengthSizeMinusOne (2 bits = 3) - out.put_u8(0xe1); // reserved (3 bits) | numOfSequenceParameterSets (5 bits = 1) - out.put_u16(sps_nal.len() as u16); - out.put_slice(sps_nal); - out.put_u8(1); // numOfPictureParameterSets - out.put_u16(pps_nal.len() as u16); - out.put_slice(pps_nal); + out.put_u8(0xe0 | sps_nals.len() as u8); // reserved (3 bits) | numOfSequenceParameterSets + for sps in sps_nals { + out.put_u16(sps.len() as u16); + out.put_slice(sps); + } + out.put_u8(pps_nals.len() as u8); // numOfPictureParameterSets + for pps in pps_nals { + out.put_u16(pps.len() as u16); + out.put_slice(pps); + } Ok(out.freeze()) } @@ -332,15 +361,20 @@ fn read_param_set_array(buf: &[u8], mut pos: usize, count: usize, params: &mut V /// Transform H.264 frames from Annex-B (inline SPS/PPS, "avc3") to /// length-prefixed NALU (out-of-band AVCDecoderConfigurationRecord, "avc1"). /// -/// The avcC is synthesized from cached SPS+PPS the first time both are -/// observed and is exposed via [`Self::avcc`]. Once [`Self::avcc`] returns -/// `Some`, all subsequent calls to [`Self::transform`] return length-prefixed -/// sample data suitable for an avc1 container (e.g. MKV `V_MPEG4/ISO/AVC` with -/// the avcC in CodecPrivate). +/// The avcC is synthesized from the active SPS+PPS and exposed via +/// [`Self::avcc`]. Once it returns `Some`, all subsequent calls to +/// [`Self::transform`] return length-prefixed sample data suitable for an avc1 +/// container (e.g. MKV `V_MPEG4/ISO/AVC` with the avcC in CodecPrivate). +/// +/// The active set is scoped to the latest keyframe: a frame that carries +/// parameter sets redefines them, so a mid-stream reconfiguration drops the +/// superseded SPS/PPS instead of accumulating them forever. pub struct Avc1 { avcc: Option, - sps: Option, - pps: Option, + /// The active SPS NALs (from the most recent keyframe that carried them). + sps: Vec, + /// The active PPS NALs. + pps: Vec, } impl Default for Avc1 { @@ -354,8 +388,8 @@ impl Avc1 { pub fn new() -> Self { Self { avcc: None, - sps: None, - pps: None, + sps: Vec::new(), + pps: Vec::new(), } } @@ -372,14 +406,15 @@ impl Avc1 { /// transform is still waiting for slice NALs (avcC may have been built /// as a side effect). pub fn transform(&mut self, payload: Bytes) -> Result> { - // Parse Annex-B NALs, strip SPS/PPS into the cache, length-prefix - // the rest. NalIterator advances the Bytes cursor; the trailing NAL - // has to be pulled separately via flush(). + // 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(). let mut buf = payload.clone(); let mut nal_iter = crate::codec::annexb::NalIterator::new(&mut buf); let mut out = BytesMut::with_capacity(payload.remaining()); - let mut sps_pps_changed = false; + let mut frame_sps: Vec = Vec::new(); + let mut frame_pps: Vec = Vec::new(); let mut emitted_any_slice = false; loop { @@ -388,19 +423,31 @@ impl Avc1 { Some(Err(e)) => return Err(e.into()), None => break, }; - if self.process_nal(&nal, &mut out, &mut sps_pps_changed)? { + if process_nal(&nal, &mut out, &mut frame_sps, &mut frame_pps)? { emitted_any_slice = true; } } if let Some(nal) = nal_iter.flush()? { - let was_slice = self.process_nal(&nal, &mut out, &mut sps_pps_changed)?; - if was_slice { + if process_nal(&nal, &mut out, &mut frame_sps, &mut frame_pps)? { emitted_any_slice = true; } } - if sps_pps_changed { + // A frame that carries parameter sets (a keyframe) redefines the active + // set; adopt it so SPS/PPS from a superseded configuration are dropped + // rather than lingering in the avcC. Per type, so a frame that updates only + // one of SPS/PPS keeps the other. + let mut changed = false; + if !frame_sps.is_empty() && frame_sps != self.sps { + self.sps = frame_sps; + changed = true; + } + if !frame_pps.is_empty() && frame_pps != self.pps { + self.pps = frame_pps; + changed = true; + } + if changed { self.rebuild_avcc()?; } @@ -411,47 +458,45 @@ impl Avc1 { Ok(Some(out.freeze())) } - /// Process one NAL: SPS/PPS go into the cache, everything else gets - /// length-prefixed and appended to `out`. Returns true if the NAL was a - /// slice (i.e. produced sample bytes). - fn process_nal(&mut self, nal: &Bytes, out: &mut BytesMut, sps_pps_changed: &mut bool) -> Result { - if nal.is_empty() { - return Ok(false); - } - let nal_type = nal[0] & 0x1f; - match nal_type { - NAL_TYPE_SPS => { - if self.sps.as_deref() != Some(nal.as_ref()) { - self.sps = Some(nal.clone()); - *sps_pps_changed = true; - } - Ok(false) - } - NAL_TYPE_PPS => { - if self.pps.as_deref() != Some(nal.as_ref()) { - self.pps = Some(nal.clone()); - *sps_pps_changed = true; - } - Ok(false) - } - _ => { - 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) - } - } - } - fn rebuild_avcc(&mut self) -> Result<()> { - let (Some(sps), Some(pps)) = (&self.sps, &self.pps) else { + if self.sps.is_empty() || self.pps.is_empty() { return Ok(()); - }; - self.avcc = Some(build_avcc(sps, pps)?); + } + self.avcc = Some(build_avcc(&self.sps, &self.pps)?); Ok(()) } } +/// Process one NAL: SPS/PPS are collected (distinctly) into this frame's sets, +/// everything else is length-prefixed and appended to `out`. Returns true if the +/// NAL was a slice (i.e. produced sample bytes). +fn process_nal( + nal: &Bytes, + out: &mut BytesMut, + frame_sps: &mut Vec, + frame_pps: &mut Vec, +) -> Result { + if nal.is_empty() { + return Ok(false); + } + match nal[0] & 0x1f { + NAL_TYPE_SPS => { + crate::codec::annexb::push_distinct(frame_sps, nal); + Ok(false) + } + NAL_TYPE_PPS => { + crate::codec::annexb::push_distinct(frame_pps, nal); + Ok(false) + } + _ => { + 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) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -518,16 +563,73 @@ mod tests { #[test] fn avcc_params_roundtrips_build_avcc() { - let sps = &[0x67, 0x42, 0xc0, 0x1f, 0xde][..]; - let pps = &[0x68, 0xce, 0x3c, 0x80][..]; + let sps = Bytes::from_static(&[0x67, 0x42, 0xc0, 0x1f, 0xde]); + let pps = Bytes::from_static(&[0x68, 0xce, 0x3c, 0x80]); - let avcc = build_avcc(sps, pps).unwrap(); + let avcc = build_avcc(std::slice::from_ref(&sps), std::slice::from_ref(&pps)).unwrap(); let (length_size, params) = avcc_params(&avcc).unwrap(); assert_eq!(length_size, 4); assert_eq!(params.len(), 2); - assert_eq!(params[0].as_ref(), sps); - assert_eq!(params[1].as_ref(), pps); + assert_eq!(params[0], sps); + assert_eq!(params[1], pps); + } + + #[test] + fn build_avcc_carries_multiple_pps() { + // A source with one SPS and two PPS (ids 0 and 1): the avcC must keep both, + // in order, so slices referencing either id stay decodable. + 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(); + // numOfSequenceParameterSets is the low 5 bits of byte 5. + assert_eq!(avcc[5] & 0x1f, 1); + + let (_, params) = avcc_params(&avcc).unwrap(); + assert_eq!(params, vec![sps, pps0, pps1]); + } + + #[test] + fn avc3_keyframe_with_two_pps_keeps_both() { + // One keyframe carrying both PPS: the synthesized avcC keeps both, in order. + let sps = &[0x67, 0x42, 0xc0, 0x1f, 0xde][..]; + let pps0 = &[0x68, 0xce, 0x3c, 0x80][..]; + let pps1 = &[0x68, 0xce, 0x3c, 0x81][..]; + let idr = &[0x65, 0x88][..]; + + let mut tx = Avc1::new(); + tx.transform(annexb_frame(&[sps, pps0, pps1, idr])).unwrap(); + + let avcc = tx.avcc().expect("avcC available"); + let (_, params) = avcc_params(avcc).unwrap(); + assert_eq!( + params.iter().map(|p| p.as_ref()).collect::>(), + vec![sps, pps0, pps1] + ); + } + + #[test] + fn avc3_reinit_drops_superseded_pps() { + // A later keyframe presents a different PPS set: the avcC adopts the new set + // and drops the old one rather than accumulating both forever. + let sps = &[0x67, 0x42, 0xc0, 0x1f, 0xde][..]; + let pps0 = &[0x68, 0xce, 0x3c, 0x80][..]; + let pps1 = &[0x68, 0xce, 0x3c, 0x81][..]; + let idr = &[0x65, 0x88][..]; + + let mut tx = Avc1::new(); + tx.transform(annexb_frame(&[sps, pps0, idr])).unwrap(); + tx.transform(annexb_frame(&[sps, pps1, idr])).unwrap(); + + let avcc = tx.avcc().expect("avcC available"); + let (_, params) = avcc_params(avcc).unwrap(); + assert_eq!( + params.iter().map(|p| p.as_ref()).collect::>(), + vec![sps, pps1], + "reinit must drop the superseded PPS" + ); } #[test] diff --git a/rs/moq-mux/src/codec/h264/split.rs b/rs/moq-mux/src/codec/h264/split.rs index 0f03458be..21624ab5c 100644 --- a/rs/moq-mux/src/codec/h264/split.rs +++ b/rs/moq-mux/src/codec/h264/split.rs @@ -31,8 +31,13 @@ pub struct Split { /// NAL here until the next start code arrives or [`flush`](Self::flush) drains it. tail: BytesMut, current: Avc3Frame, - sps: Option, - pps: Option, + /// 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, } @@ -42,8 +47,10 @@ struct Avc3Frame { chunks: BytesMut, contains_idr: bool, contains_slice: bool, - contains_sps: bool, - contains_pps: 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 { @@ -58,8 +65,8 @@ impl Split { Self { tail: BytesMut::new(), current: Avc3Frame::default(), - sps: None, - pps: None, + sps: Vec::new(), + pps: Vec::new(), zero: None, pending: Vec::new(), } @@ -116,42 +123,30 @@ impl Split { match nal_type { Some(Avc3NalType::Sps) => { self.maybe_start_frame(pts)?; - if self.sps.as_ref().is_some_and(|cached| cached != &nal) { - // SPS changed mid-stream. The cached PPS is tied to the old - // SPS and may already have been appended to current.chunks - // earlier in this AU; reset so the new SPS+PPS pair is the - // only parameter set we emit. - self.pps = None; - self.current.chunks.clear(); - self.current.contains_pps = false; - self.current.contains_sps = false; - } - self.sps = Some(nal.clone()); - self.current.contains_sps = true; + // 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)?; - self.pps = Some(nal.clone()); - self.current.contains_pps = true; + 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) => { - if !self.current.contains_sps - && let Some(sps) = self.sps.clone() - { - self.current.chunks.extend_from_slice(&START_CODE); - self.current.chunks.extend_from_slice(&sps); - self.current.contains_sps = true; - } - if !self.current.contains_pps - && let Some(pps) = self.pps.clone() - { - self.current.chunks.extend_from_slice(&START_CODE); - self.current.chunks.extend_from_slice(&pps); - self.current.contains_pps = true; - } + // 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; } @@ -182,8 +177,8 @@ impl Split { let keyframe = self.current.contains_idr; self.current.contains_idr = false; self.current.contains_slice = false; - self.current.contains_sps = false; - self.current.contains_pps = false; + self.current.sps_seen.clear(); + self.current.pps_seen.clear(); self.pending.push(crate::container::Frame { timestamp: pts, @@ -326,4 +321,52 @@ mod tests { 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/import.rs b/rs/moq-mux/src/codec/h265/import.rs index 12a32d59c..b030e0504 100644 --- a/rs/moq-mux/src/codec/h265/import.rs +++ b/rs/moq-mux/src/codec/h265/import.rs @@ -72,6 +72,11 @@ impl Import { 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() diff --git a/rs/moq-mux/src/codec/h265/mod.rs b/rs/moq-mux/src/codec/h265/mod.rs index 569893fbd..edae02cc2 100644 --- a/rs/moq-mux/src/codec/h265/mod.rs +++ b/rs/moq-mux/src/codec/h265/mod.rs @@ -27,6 +27,9 @@ pub enum Error { #[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, @@ -162,11 +165,18 @@ pub(crate) fn config_from_hvcc(hvcc: &[u8]) -> Result, - vps: Option, - sps: Option, - pps: Option, + /// The active VPS NALs (from the most recent keyframe that carried them). + vps: Vec, + /// The active SPS NALs. + sps: Vec, + /// The active PPS NALs. + pps: Vec, } impl Default for Hvc1 { @@ -180,9 +190,9 @@ impl Hvc1 { pub fn new() -> Self { Self { hvcc: None, - vps: None, - sps: None, - pps: None, + vps: Vec::new(), + sps: Vec::new(), + pps: Vec::new(), } } @@ -203,7 +213,9 @@ impl Hvc1 { let mut nal_iter = crate::codec::annexb::NalIterator::new(&mut buf); let mut out = BytesMut::with_capacity(payload.remaining()); - let mut params_changed = false; + let mut frame_vps: Vec = Vec::new(); + let mut frame_sps: Vec = Vec::new(); + let mut frame_pps: Vec = Vec::new(); let mut emitted_any_slice = false; loop { @@ -212,19 +224,35 @@ impl Hvc1 { Some(Err(e)) => return Err(e.into()), None => break, }; - if self.process_nal(&nal, &mut out, &mut params_changed)? { + if process_nal(&nal, &mut out, &mut frame_vps, &mut frame_sps, &mut frame_pps)? { emitted_any_slice = true; } } if let Some(nal) = nal_iter.flush()? { - let was_slice = self.process_nal(&nal, &mut out, &mut params_changed)?; - if was_slice { + if process_nal(&nal, &mut out, &mut frame_vps, &mut frame_sps, &mut frame_pps)? { emitted_any_slice = true; } } - if params_changed { + // A frame that carries parameter sets (a keyframe) redefines the active + // set; adopt it so a superseded configuration's VPS/SPS/PPS are dropped + // rather than lingering in the hvcC. Per type, so a frame that updates only + // one kind keeps the others. + let mut changed = false; + if !frame_vps.is_empty() && frame_vps != self.vps { + self.vps = frame_vps; + changed = true; + } + if !frame_sps.is_empty() && frame_sps != self.sps { + self.sps = frame_sps; + changed = true; + } + if !frame_pps.is_empty() && frame_pps != self.pps { + self.pps = frame_pps; + changed = true; + } + if changed { self.rebuild_hvcc()?; } @@ -235,71 +263,82 @@ impl Hvc1 { Ok(Some(out.freeze())) } - fn process_nal(&mut self, nal: &Bytes, out: &mut BytesMut, params_changed: &mut bool) -> Result { - if nal.is_empty() { - return Ok(false); - } - // HEVC NAL header is 2 bytes; type is bits 1..=6 of byte 0. - let nal_unit_type = (nal[0] >> 1) & 0x3f; - let nal_type = NALUnitType::from(nal_unit_type); - - match nal_type { - NALUnitType::VpsNut => { - if self.vps.as_deref() != Some(nal.as_ref()) { - self.vps = Some(nal.clone()); - *params_changed = true; - } - Ok(false) - } - NALUnitType::SpsNut => { - if self.sps.as_deref() != Some(nal.as_ref()) { - self.sps = Some(nal.clone()); - *params_changed = true; - } - Ok(false) - } - NALUnitType::PpsNut => { - if self.pps.as_deref() != Some(nal.as_ref()) { - self.pps = Some(nal.clone()); - *params_changed = true; - } - Ok(false) - } - _ => { - 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) - } - } - } - fn rebuild_hvcc(&mut self) -> Result<()> { - let (Some(vps), Some(sps), Some(pps)) = (&self.vps, &self.sps, &self.pps) else { + if self.vps.is_empty() || self.sps.is_empty() || self.pps.is_empty() { return Ok(()); - }; - self.hvcc = Some(build_hvcc(vps, sps, pps)?); + } + self.hvcc = Some(build_hvcc(&self.vps, &self.sps, &self.pps)?); Ok(()) } } +/// Process one NAL: VPS/SPS/PPS are collected (distinctly) into this frame's +/// sets, everything else is length-prefixed and appended to `out`. Returns true +/// if the NAL was a slice (i.e. produced sample bytes). +fn process_nal( + nal: &Bytes, + out: &mut BytesMut, + frame_vps: &mut Vec, + frame_sps: &mut Vec, + frame_pps: &mut Vec, +) -> Result { + if nal.is_empty() { + return Ok(false); + } + // HEVC NAL header is 2 bytes; type is bits 1..=6 of byte 0. + match NALUnitType::from((nal[0] >> 1) & 0x3f) { + NALUnitType::VpsNut => { + crate::codec::annexb::push_distinct(frame_vps, nal); + Ok(false) + } + NALUnitType::SpsNut => { + crate::codec::annexb::push_distinct(frame_sps, nal); + Ok(false) + } + NALUnitType::PpsNut => { + crate::codec::annexb::push_distinct(frame_pps, nal); + Ok(false) + } + _ => { + 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) + } + } +} + /// Build an HEVCDecoderConfigurationRecord (ISO/IEC 14496-15 §8.3.3). -/// Single-layer streams only. -pub(crate) fn build_hvcc(vps_nal: &[u8], sps_nal: &[u8], pps_nal: &[u8]) -> Result { - for (label, nal) in [("VPS", vps_nal), ("SPS", sps_nal), ("PPS", pps_nal)] { - if nal.len() > u16::MAX as usize { - return Err(Error::NalTooLargeForHvcc(label, nal.len())); +/// 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]) -> Result { + let first_sps = sps_nals.first().ok_or(Error::MissingSps)?; + for (label, nals) in [("VPS", vps_nals), ("SPS", sps_nals), ("PPS", pps_nals)] { + if nals.len() > u16::MAX as usize { + return Err(Error::TooManyNals(label, nals.len())); + } + for nal in nals { + if nal.len() > u16::MAX as usize { + return Err(Error::NalTooLargeForHvcc(label, nal.len())); + } } } - let sps = SpsNALUnit::parse(&mut &sps_nal[..]).map_err(|_| Error::SpsParse)?; + 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.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; - let mut out = BytesMut::with_capacity(23 + vps_nal.len() + sps_nal.len() + pps_nal.len() + 9 * 3); + let params_len: usize = vps_nals + .iter() + .chain(sps_nals) + .chain(pps_nals) + .map(|n| 2 + n.len()) + .sum(); + let mut out = BytesMut::with_capacity(23 + 3 * 3 + params_len); out.put_u8(1); // configurationVersion out.put_u8(((profile.profile_space & 0x3) << 6) | ((profile.tier_flag as u8) << 5) | (profile.profile_idc & 0x1f)); out.put_slice(&compat); @@ -312,17 +351,19 @@ pub(crate) fn build_hvcc(vps_nal: &[u8], sps_nal: &[u8], pps_nal: &[u8]) -> Resu out.put_u8(0xf8 | (sps.rbsp.bit_depth_chroma_minus8 & 0x7)); out.put_u16(0); // avgFrameRate unspecified out.put_u8(((num_temporal_layers & 0x7) << 3) | ((sps.rbsp.sps_temporal_id_nesting_flag as u8) << 2) | 0x3); - out.put_u8(3); // numOfArrays + out.put_u8(3); // numOfArrays (VPS, SPS, PPS) - for (nal_type, nal) in [ - (u8::from(NALUnitType::VpsNut), vps_nal), - (u8::from(NALUnitType::SpsNut), sps_nal), - (u8::from(NALUnitType::PpsNut), pps_nal), + for (nal_type, nals) in [ + (u8::from(NALUnitType::VpsNut), vps_nals), + (u8::from(NALUnitType::SpsNut), sps_nals), + (u8::from(NALUnitType::PpsNut), pps_nals), ] { out.put_u8(0x80 | (nal_type & 0x3f)); // array_completeness = 1 - out.put_u16(1); // numNalus - out.put_u16(nal.len() as u16); - out.put_slice(nal); + out.put_u16(nals.len() as u16); // numNalus + for nal in nals { + out.put_u16(nal.len() as u16); + out.put_slice(nal); + } } Ok(out.freeze()) diff --git a/rs/moq-mux/src/codec/h265/split.rs b/rs/moq-mux/src/codec/h265/split.rs index b84c12bda..e4bbf5b62 100644 --- a/rs/moq-mux/src/codec/h265/split.rs +++ b/rs/moq-mux/src/codec/h265/split.rs @@ -27,9 +27,15 @@ pub struct Split { /// NAL here until the next start code arrives or [`flush`](Self::flush) drains it. tail: BytesMut, current: Au, - vps: Option, - sps: Option, - pps: Option, + /// 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, } @@ -39,9 +45,12 @@ struct Au { chunks: BytesMut, contains_idr: bool, contains_slice: bool, - contains_vps: bool, - contains_sps: bool, - contains_pps: 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 { @@ -56,9 +65,9 @@ impl Split { Self { tail: BytesMut::new(), current: Au::default(), - vps: None, - sps: None, - pps: None, + vps: Vec::new(), + sps: Vec::new(), + pps: Vec::new(), zero: None, pending: Vec::new(), } @@ -119,31 +128,15 @@ impl Split { match nal_type { NALUnitType::VpsNut => { self.maybe_start_frame(pts)?; - self.vps = Some(nal.clone()); - self.current.contains_vps = true; + crate::codec::annexb::push_distinct(&mut self.current.vps_seen, &nal); } NALUnitType::SpsNut => { self.maybe_start_frame(pts)?; - - // SPS changed mid-stream. Cached PPS is tied to the old SPS and - // may already have been appended to current.chunks earlier in - // this AU; reset so the new VPS+SPS+PPS triple is the only - // parameter set we emit. - if self.sps.as_ref().is_some_and(|cached| cached != &nal) { - self.pps = None; - self.current.chunks.clear(); - self.current.contains_vps = false; - self.current.contains_sps = false; - self.current.contains_pps = false; - } - - self.sps = Some(nal.clone()); - self.current.contains_sps = true; + crate::codec::annexb::push_distinct(&mut self.current.sps_seen, &nal); } NALUnitType::PpsNut => { self.maybe_start_frame(pts)?; - self.pps = Some(nal.clone()); - self.current.contains_pps = true; + crate::codec::annexb::push_distinct(&mut self.current.pps_seen, &nal); } NALUnitType::AudNut | NALUnitType::PrefixSeiNut | NALUnitType::SuffixSeiNut => { self.maybe_start_frame(pts)?; @@ -155,28 +148,23 @@ impl Split { | NALUnitType::BlaWRadl | NALUnitType::BlaWLp | NALUnitType::CraNut => { - // Insert cached VPS/SPS/PPS before keyframes if not already present. - if !self.current.contains_vps - && let Some(vps) = self.vps.clone() - { - self.current.chunks.extend_from_slice(&START_CODE); - self.current.chunks.extend_from_slice(&vps); - self.current.contains_vps = true; - } - if !self.current.contains_sps - && let Some(sps) = self.sps.clone() - { - self.current.chunks.extend_from_slice(&START_CODE); - self.current.chunks.extend_from_slice(&sps); - self.current.contains_sps = true; - } - if !self.current.contains_pps - && let Some(pps) = self.pps.clone() - { - self.current.chunks.extend_from_slice(&START_CODE); - self.current.chunks.extend_from_slice(&pps); - self.current.contains_pps = true; - } + // 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; @@ -218,9 +206,9 @@ impl Split { let keyframe = self.current.contains_idr; self.current.contains_idr = false; self.current.contains_slice = false; - self.current.contains_vps = false; - self.current.contains_sps = false; - self.current.contains_pps = false; + self.current.vps_seen.clear(); + self.current.sps_seen.clear(); + self.current.pps_seen.clear(); self.pending.push(crate::container::Frame { timestamp: pts, @@ -323,4 +311,46 @@ mod tests { 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 56b37abc7..826069091 100644 --- a/rs/moq-mux/src/codec/legacy.rs +++ b/rs/moq-mux/src/codec/legacy.rs @@ -146,6 +146,11 @@ impl Import { } } + /// The MoQ track name. + pub fn name(&self) -> &str { + self.track.name() + } + /// Finish the track, flushing the current group. pub fn finish(&mut self) -> crate::Result<()> { self.track.finish()?; diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index 3801444e8..2fdda1d77 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -22,7 +22,6 @@ use hang::catalog::{AudioConfig, VideoCodec, VideoConfig}; use crate::catalog::hang::Container as HangContainer; use crate::codec::h264::Avc1; use crate::codec::h265::Hvc1; -use crate::container::ts::scte35; use crate::container::{Consumer, Frame}; /// Per-track video transform that bridges between codec shapes. @@ -133,20 +132,17 @@ impl ExportSource { }) } - /// Subscribe to a SCTE-35 cue rendition. No codec-shape transform and no - /// description: the frames carry the verbatim `splice_info_section` bytes that - /// the muxer writes back out as private sections. - pub fn for_scte35( + /// Subscribe to a verbatim `mpegts` stream rendition (SCTE-35, private PES, ...). + /// No codec-shape transform and no description: the frames are Legacy-framed + /// verbatim bytes the muxer writes back out as PES or private sections. + pub fn for_stream( broadcast: &moq_net::BroadcastConsumer, name: &str, - config: &scte35::Config, latency: Duration, ) -> Result { - let media: HangContainer = (&config.container).try_into()?; - Ok(Self { state: SourceState::Subscribing(broadcast.track(name)?.subscribe(None)?), - media: Some(media), + media: Some(HangContainer::Legacy), latency, transform: None, description: None, @@ -226,11 +222,13 @@ impl ExportSource { } fn refresh_description(&mut self) { - if self.description.is_some() { - return; - } + // Track the transform's record even after it is first set: a mid-stream + // reconfiguration rebuilds the avcC/hvcC with a new parameter set, and the + // muxer re-injects from this on every keyframe, so a stale record would + // carry superseded SPS/PPS. if let Some(transform) = self.transform.as_ref() && let Some(d) = transform.codec_private() + && self.description.as_ref() != Some(d) { self.description = Some(d.clone()); } diff --git a/rs/moq-mux/src/container/ts/catalog.rs b/rs/moq-mux/src/container/ts/catalog.rs new file mode 100644 index 000000000..6e1112b41 --- /dev/null +++ b/rs/moq-mux/src/container/ts/catalog.rs @@ -0,0 +1,221 @@ +//! MPEG-TS catalog extension (the `mpegts` section). +//! +//! The `mpegts` section carries everything needed to faithfully re-mux a broadcast +//! back to MPEG-TS that doesn't belong in the codec-neutral media configs: one +//! entry per track (its original PID and PMT descriptors), a `verbatim` carriage +//! record for every elementary stream we don't decode (SCTE-35, teletext, DVB +//! subtitles, private data, ...), and the program-level PMT descriptors. Demuxed +//! media tracks keep their codec config in the base `video`/`audio` sections; only +//! their MPEG-TS identity lands here. + +use std::collections::BTreeMap; + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use serde_with::base64::Base64; +use serde_with::serde_as; + +use crate::catalog::hang::CatalogExt; + +/// The `mpegts` catalog section. +/// +/// Omitted from the catalog when empty, so a broadcast that needs none of it stays +/// byte-identical to one without the extension. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Mpegts { + /// Per-track MPEG-TS info, keyed by MoQ track name. Media tracks record their + /// PID and PMT descriptors; undecoded tracks add a [`Verbatim`] carriage record. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tracks: BTreeMap, + + /// PMT program-level descriptors (`program_info`), carried verbatim. Export + /// re-emits these; the SCTE-35 'CUEI' registration is derived when absent. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub program_descriptors: Vec, +} + +impl Mpegts { + /// True when the section carries nothing, so it's omitted from the catalog. + pub fn is_empty(&self) -> bool { + self.tracks.is_empty() && self.program_descriptors.is_empty() + } +} + +/// One track's MPEG-TS identity and signaling. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Track { + /// Original MPEG-TS PID. Export prefers it so PID cross-references survive; + /// tracks without an entry are renumbered. + pub pid: u16, + + /// PMT ES-level descriptors (ISO-639 language, registration, ...), carried + /// verbatim so they survive the round-trip. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub descriptors: Vec, + + /// Present when the stream is carried verbatim (not decoded into `video`/`audio`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verbatim: Option, +} + +impl Track { + /// A new media track entry (decoded; no verbatim carriage), recording its PID. + pub fn new(pid: u16) -> Self { + Self { + pid, + descriptors: Vec::new(), + verbatim: None, + } + } +} + +/// Carriage record for an undecoded elementary stream carried byte-for-byte. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Verbatim { + /// PMT `stream_type` to re-announce (0x86 SCTE-35, 0x06 private PES, 0x05 + /// private sections, ...). + pub stream_type: u8, + + /// How the verbatim payload is framed, so export knows how to repacketize it. + #[serde(default)] + pub framing: Framing, + + /// Original PES `stream_id` (e.g. 0xBD private_stream_1 for teletext/DVB + /// subtitles/DVB AC-3, 0xC0-0xDF audio). Preserved so export re-emits the PES + /// under its real id rather than relabeling it, which strict broadcast demuxers + /// and TR 101 290 analyzers reject. `None` for section framing or a non-TS + /// source; export then falls back to `private_stream_1`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream_id: Option, +} + +impl Verbatim { + /// A new verbatim carriage record of the given `stream_type` and `framing`. + pub fn new(stream_type: u8, framing: Framing) -> Self { + Self { + stream_type, + framing, + stream_id: None, + } + } +} + +/// How a verbatim stream's payload is framed on the wire, so export can repacketize it. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum Framing { + /// Packetized Elementary Stream: each frame is one PES payload (access unit), + /// timestamped by its PTS. Used by private PES, teletext, DVB subtitles, ... + #[default] + Pes, + /// Private sections (table_id + section_length framing). Each frame is one + /// complete section. Used by SCTE-35 and other private-section signaling. + Section, +} + +/// One PMT descriptor, carried verbatim so language/registration/etc. survive the +/// round-trip without a per-descriptor parser. +#[serde_as] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Descriptor { + /// The descriptor tag (e.g. 0x05 registration, 0x0A ISO-639 language). + pub tag: u8, + /// The descriptor body, base64-encoded in the catalog. + #[serde_as(as = "Base64")] + pub data: Bytes, +} + +/// The application catalog extension carrying the `mpegts` section. Empty by +/// default, so the section is omitted until an MPEG-TS detail is recorded. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[non_exhaustive] +pub struct Ext { + #[serde(default, skip_serializing_if = "Mpegts::is_empty")] + pub mpegts: Mpegts, +} + +impl CatalogExt for Ext {} + +/// An extension that can carry an `mpegts` catalog section. +/// +/// 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>; +} + +impl Catalog for () { + 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) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn empty_section_omitted() { + // An empty `mpegts` section serializes to `{}` so a media-only broadcast stays + // byte-identical to one without the extension. + let ext = Ext::default(); + assert_eq!(serde_json::to_string(&ext).unwrap(), "{}"); + } + + #[test] + fn section_roundtrip() { + let mut mpegts = Mpegts::default(); + // A media track: PID + a language descriptor, no verbatim carriage. + mpegts.tracks.insert( + "audio".to_string(), + Track { + pid: 0x101, + descriptors: vec![Descriptor { + tag: 0x0a, + data: Bytes::from_static(b"eng\x00"), + }], + verbatim: None, + }, + ); + // A verbatim SCTE-35 track. + mpegts.tracks.insert( + ".scte35".to_string(), + Track { + pid: 0x102, + descriptors: Vec::new(), + verbatim: Some(Verbatim::new(0x86, Framing::Section)), + }, + ); + mpegts.program_descriptors.push(Descriptor { + tag: 0x05, + data: Bytes::from_static(b"CUEI"), + }); + + let json = serde_json::to_string(&Ext { mpegts: mpegts.clone() }).unwrap(); + // Descriptor bytes are base64 ("CUEI" -> "Q1VFSQ=="). + assert!(json.contains("\"Q1VFSQ==\""), "descriptor data is base64: {json}"); + + let parsed: Ext = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.mpegts, mpegts, "mpegts section round-trips"); + } +} diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index 376c86049..419223125 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -37,7 +37,7 @@ use crate::codec::annexb; use crate::container::{ExportSource, Frame}; use super::adts; -use super::scte35; +use super::catalog; /// PID of the single program's PMT. const PMT_PID: u16 = 0x1000; @@ -53,7 +53,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, @@ -61,6 +61,8 @@ pub struct Export { tracks: HashMap, /// Continuity counter per PID (PAT, PMT, and each elementary stream). counters: HashMap, + /// PMT program-level descriptors captured on import, re-emitted in the PMT. + program_descriptors: Vec, /// Program tables, built once the track layout is known. psi: Option, @@ -74,6 +76,9 @@ struct Track { finished: bool, pid: u16, kind: Kind, + /// PMT ES-level descriptors to re-announce, captured verbatim on import (language, + /// registration, ...). Empty for non-TS sources; AC-3/E-AC-3 then synthesize one. + descriptors: Vec, } #[derive(Clone)] @@ -92,8 +97,15 @@ enum Kind { Ac3, /// E-AC-3 (ATSC stream_type 0x87), carried verbatim. Eac3, - /// SCTE-35: private sections (stream_type 0x86), carried verbatim. - Scte35, + /// An undecoded elementary stream carried verbatim (SCTE-35, private PES, + /// teletext, ...). Re-announced in the PMT with its recorded `stream_type` and + /// repacketized per its `framing`. `stream_id` is the original PES stream_id to + /// re-emit (PES framing only; `None` falls back to `private_stream_1`). + Verbatim { + stream_type: u8, + framing: catalog::Framing, + stream_id: Option, + }, } /// The program tables plus the resolved PID layout. @@ -110,6 +122,8 @@ struct PesUnit { is_video: bool, keyframe: bool, timestamp: Timestamp, + /// Explicit PES stream_id (verbatim PES); `None` derives it from `is_video`. + stream_id: Option, } impl Export { @@ -119,7 +133,7 @@ impl Export { } /// Subscribe to `broadcast`, selecting an explicit catalog format. Media only; - /// any catalog extension (e.g. `.scte35` cues) is ignored. + /// any catalog extension (e.g. the `mpegts` verbatim streams) is ignored. pub async fn with_catalog_format( broadcast: moq_net::BroadcastConsumer, catalog_format: CatalogFormat, @@ -128,11 +142,12 @@ impl Export { } } -impl Export { - /// Subscribe to `broadcast`, exporting its `.scte35` cue tracks back to MPEG-TS - /// alongside the media. The `Self` type pins the extension, so callers write - /// `Export::with_scte35(..)` with no turbofish (the plain constructors are media-only). - pub async fn with_scte35( +impl Export { + /// Subscribe to `broadcast`, exporting its `mpegts` verbatim streams (SCTE-35, + /// private data, ...) back to MPEG-TS alongside the media. The `Self` type pins + /// the extension, so callers write `Export::with_ts(..)` with no turbofish (the + /// plain constructors are media-only). + pub async fn with_ts( broadcast: moq_net::BroadcastConsumer, catalog_format: CatalogFormat, ) -> Result { @@ -140,7 +155,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. async fn build(broadcast: moq_net::BroadcastConsumer, catalog_format: CatalogFormat) -> Result { @@ -151,6 +166,7 @@ impl Export { latency: Duration::ZERO, tracks: HashMap::new(), counters: HashMap::new(), + program_descriptors: Vec::new(), psi: None, last_psi: None, }) @@ -260,11 +276,13 @@ impl Export { } fn update_catalog(&mut self, mut catalog: Catalog) -> anyhow::Result<()> { - // The cue tracks live in the extension. The trait only exposes `scte35_mut`, - // and this snapshot is owned, so clone the section out (`()` yields the - // empty default: zero cue tracks). - let scte35 = catalog.scte35_mut().cloned().unwrap_or_default(); + // 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 + // empty default: no verbatim streams, no preserved PIDs/descriptors). + let mpegts = catalog.mpegts_mut().cloned().unwrap_or_default(); + self.program_descriptors = mpegts.program_descriptors.clone(); + // The desired track set: media renditions plus the verbatim streams. let mut active: HashMap = HashMap::new(); for name in catalog.video.renditions.keys() { active.insert(name.clone(), ()); @@ -272,8 +290,10 @@ impl Export { for name in catalog.audio.renditions.keys() { active.insert(name.clone(), ()); } - for name in scte35.renditions.keys() { - active.insert(name.clone(), ()); + for (name, track) in mpegts.tracks.iter() { + if track.verbatim.is_some() { + active.insert(name.clone(), ()); + } } // The program tables are written once; reject layout changes afterwards. @@ -293,75 +313,118 @@ impl Export { return Ok(()); } - let mut next_pid = self - .tracks - .values() - .map(|t| t.pid) - .max() - .map(|p| p + 1) - .unwrap_or(FIRST_ES_PID); + // Assign a PID to every desired track: prefer the original recorded in the + // `mpegts` section, then fill the rest from FIRST_ES_PID. The importer fills + // PIDs, descriptors, and stream_ids across several catalog publishes, so this + // runs every snapshot until the PMT is built and the tracks below are + // *refreshed*, not latched from the first (partial) snapshot. + let mut used: Vec = vec![0x0000, PMT_PID, 0x1FFF]; + let mut pids: HashMap = HashMap::new(); + for name in active.keys() { + if let Some(pid) = mpegts.tracks.get(name).map(|t| t.pid) + && !used.contains(&pid) + { + used.push(pid); + pids.insert(name.clone(), pid); + } + } + for name in active.keys() { + if !pids.contains_key(name) { + let mut pid = FIRST_ES_PID; + while used.contains(&pid) { + pid += 1; + } + used.push(pid); + pids.insert(name.clone(), pid); + } + } + // Reuse each track's existing source (and any pending frame) by name; refresh + // its PID, kind, and descriptors from this snapshot. Drop tracks no longer present. + let mut old = std::mem::take(&mut self.tracks); for (name, config) in catalog.video.renditions.iter() { - if self.tracks.contains_key(name) { - continue; - } let kind = video_kind(config, name)?; - let source = ExportSource::for_video(&self.broadcast, name, config, self.latency)?; - self.tracks.insert( - name.clone(), - Track { - source, - pending: None, - finished: false, - pid: next_pid, - kind, - }, - ); - next_pid += 1; + let descriptors = track_descriptors(&mpegts, name); + let pid = pids[name]; + match old.remove(name) { + Some(mut track) => { + track.pid = pid; + track.kind = kind; + track.descriptors = descriptors; + self.tracks.insert(name.clone(), track); + } + None => { + let source = ExportSource::for_video(&self.broadcast, name, config, self.latency)?; + self.insert_track(name, source, pid, kind, descriptors); + } + } } - for (name, config) in catalog.audio.renditions.iter() { - if self.tracks.contains_key(name) { - continue; - } let kind = audio_kind(config, name)?; - let source = ExportSource::for_audio(&self.broadcast, name, config, self.latency)?; - self.tracks.insert( - name.clone(), - Track { - source, - pending: None, - finished: false, - pid: next_pid, - kind, - }, - ); - next_pid += 1; + let descriptors = track_descriptors(&mpegts, name); + let pid = pids[name]; + match old.remove(name) { + Some(mut track) => { + track.pid = pid; + track.kind = kind; + track.descriptors = descriptors; + self.tracks.insert(name.clone(), track); + } + None => { + let source = ExportSource::for_audio(&self.broadcast, name, config, self.latency)?; + self.insert_track(name, source, pid, kind, descriptors); + } + } } - - for (name, config) in scte35.renditions.iter() { - if self.tracks.contains_key(name) { + for (name, track) in mpegts.tracks.iter() { + let Some(verbatim) = &track.verbatim else { continue; + }; + let kind = Kind::Verbatim { + stream_type: verbatim.stream_type, + framing: verbatim.framing, + stream_id: verbatim.stream_id, + }; + let descriptors = track.descriptors.clone(); + let pid = pids[name]; + match old.remove(name) { + Some(mut existing) => { + existing.pid = pid; + existing.kind = kind; + existing.descriptors = descriptors; + self.tracks.insert(name.clone(), existing); + } + None => { + let source = ExportSource::for_stream(&self.broadcast, name, self.latency)?; + self.insert_track(name, source, pid, kind, descriptors); + } } - let kind = scte35_kind(config, name)?; - let source = ExportSource::for_scte35(&self.broadcast, name, config, self.latency)?; - self.tracks.insert( - name.clone(), - Track { - source, - pending: None, - finished: false, - pid: next_pid, - kind, - }, - ); - next_pid += 1; } - - self.tracks.retain(|name, _| active.contains_key(name)); Ok(()) } + /// Insert a freshly created export track. + fn insert_track( + &mut self, + name: &str, + source: ExportSource, + pid: u16, + kind: Kind, + descriptors: Vec, + ) { + self.tracks.insert( + name.to_string(), + Track { + source, + pending: None, + finished: false, + pid, + kind, + descriptors, + }, + ); + } + /// Header is ready when every track's [`ExportSource`] has resolved its /// codec config (from the catalog `description`, or built by the transform). fn header_ready(&self) -> bool { @@ -374,13 +437,22 @@ impl Export { let mut tracks: Vec<&Track> = self.tracks.values().collect(); tracks.sort_by_key(|t| t.pid); - // SCTE-35 cues are stamped on the video clock (and SCTE carries no PTS for the PCR), - // so a cue program needs a video track; audio alone would leave the cues pinned to zero. - let has_scte = tracks.iter().any(|t| matches!(t.kind, Kind::Scte35)); + // Section-framed verbatim streams (SCTE-35, ...) are stamped on the video clock + // and carry no PTS for the PCR, so they need a video track; audio alone would + // leave them pinned to zero. + let needs_clock = tracks.iter().any(|t| { + matches!( + &t.kind, + Kind::Verbatim { + framing: catalog::Framing::Section, + .. + } + ) + }); let video = tracks.iter().find(|t| matches!(t.kind, Kind::Video(_))); anyhow::ensure!( - !has_scte || video.is_some(), - "TS export of SCTE-35 requires a video track for the program clock" + !needs_clock || video.is_some(), + "TS export of section-framed verbatim streams (e.g. SCTE-35) requires a video track for the program clock" ); let pcr_pid = video .or_else(|| { @@ -394,22 +466,27 @@ impl Export { let es_info = tracks .iter() .map(|t| { - Ok(EsInfo { - stream_type: match t.kind { - Kind::Video(stream_type) => stream_type, - Kind::Aac { .. } => StreamType::AdtsAac, - // Half-rate MPEG-2 BC audio (< 32 kHz) re-announces as 0x04; - // the full rates are MPEG-1 (0x03). The catalog sample rate - // came from the frame header, so the mapping is faithful. - Kind::Mp2 { sample_rate } if sample_rate < 32000 => StreamType::Mpeg2HalvedSampleRateAudio, - Kind::Mp2 { .. } => StreamType::Mpeg1Audio, - Kind::Ac3 => StreamType::DolbyDigitalUpToSixChannelAudio, - Kind::Eac3 => StreamType::DolbyDigitalPlusUpTo16ChannelAudioForAtsc, - Kind::Scte35 => StreamType::Dts8ChannelLosslessAudio, - }, - elementary_pid: Pid::new(t.pid)?, - // ATSC pairs the Dolby stream types with a registration descriptor. - descriptors: match t.kind { + let stream_type = match &t.kind { + Kind::Video(stream_type) => *stream_type, + Kind::Aac { .. } => StreamType::AdtsAac, + // Half-rate MPEG-2 BC audio (< 32 kHz) re-announces as 0x04; the full + // rates are MPEG-1 (0x03). The catalog sample rate came from the frame + // header, so the mapping is faithful. + Kind::Mp2 { sample_rate } if *sample_rate < 32000 => StreamType::Mpeg2HalvedSampleRateAudio, + Kind::Mp2 { .. } => StreamType::Mpeg1Audio, + Kind::Ac3 => StreamType::DolbyDigitalUpToSixChannelAudio, + Kind::Eac3 => StreamType::DolbyDigitalPlusUpTo16ChannelAudioForAtsc, + Kind::Verbatim { stream_type, .. } => { + StreamType::from_u8(*stream_type).map_err(anyhow::Error::msg)? + } + }; + // Prefer the descriptors captured verbatim on import; otherwise synthesize + // the ATSC Dolby registration so a fresh (non-TS) AC-3/E-AC-3 track is + // still announced the way the import path expects. + let descriptors = if !t.descriptors.is_empty() { + to_pmt_descriptors(&t.descriptors) + } else { + match &t.kind { Kind::Ac3 => vec![Descriptor { tag: 0x05, data: b"AC-3".to_vec(), @@ -419,14 +496,32 @@ impl Export { data: b"EAC3".to_vec(), }], _ => Vec::new(), - }, + } + }; + Ok(EsInfo { + stream_type, + elementary_pid: Pid::new(t.pid)?, + descriptors, }) }) .collect::>>()?; - // SCTE-35 is announced by a program-level 'CUEI' registration descriptor; - // the import keys detection off it (stream_type 0x86 alone is ambiguous). - let program_info = if tracks.iter().any(|t| matches!(t.kind, Kind::Scte35)) { + // Re-emit the captured program-level descriptors. With none (a non-TS source), + // derive the SCTE-35 'CUEI' registration when a 0x86 verbatim stream is present. + let program_info = if !self.program_descriptors.is_empty() { + to_pmt_descriptors(&self.program_descriptors) + } else if tracks.iter().any(|t| { + // Only derive CUEI for section-framed 0x86 (SCTE-35); a PES-framed 0x86 + // (e.g. DTS audio) must not advertise SCTE-35 section signaling. + matches!( + &t.kind, + Kind::Verbatim { + stream_type: 0x86, + framing: catalog::Framing::Section, + .. + } + ) + }) { vec![Descriptor { tag: 0x05, data: b"CUEI".to_vec(), @@ -478,8 +573,8 @@ impl Export { 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. SCTE-35 - // carries no PES payload; the section is written separately below. + // resolved avcC/hvcC to rewrite length-prefixed NALs as Annex-B. Section-framed + // verbatim streams carry no PES payload; the section is written separately below. let es_payload = match &kind { Kind::Video(stream_type) => Some(video_es_payload(*stream_type, track.source.description(), &frame)?), Kind::Aac { @@ -494,9 +589,16 @@ impl Export { Some(framed) } // Legacy audio frames were ingested whole (framing header included), so - // they pass through untouched. + // they pass through untouched. PES-framed verbatim payloads likewise. Kind::Mp2 { .. } | Kind::Ac3 | Kind::Eac3 => Some(frame.payload.to_vec()), - Kind::Scte35 => None, + Kind::Verbatim { + framing: catalog::Framing::Pes, + .. + } => Some(frame.payload.to_vec()), + Kind::Verbatim { + framing: catalog::Framing::Section, + .. + } => None, }; let mut out = Vec::with_capacity(TsPacket::SIZE); @@ -516,15 +618,24 @@ impl Export { } match es_payload { - // SCTE-35 rides in private sections, not PES; carry the bytes verbatim. + // Section-framed verbatim (SCTE-35, ...) rides in private sections, not PES; + // carry the bytes verbatim. None => self.write_section(&mut out, pid, &frame.payload)?, Some(es_payload) => { + // Verbatim PES re-emits its original stream_id (falling back to + // private_stream_1 for an undecoded stream with none recorded); media + // derives it from is_video. + let stream_id = match &kind { + Kind::Verbatim { stream_id, .. } => Some(stream_id.unwrap_or(StreamId::PRIVATE_STREAM_1)), + _ => None, + }; let unit = PesUnit { pid, is_pcr, is_video, keyframe: frame.keyframe, timestamp: frame.timestamp, + stream_id, }; self.write_pes(&mut out, &unit, &es_payload)?; } @@ -540,10 +651,10 @@ impl Export { /// Packetize a PES payload into 188-byte TS packets. fn write_pes(&mut self, out: &mut Vec, unit: &PesUnit, payload: &[u8]) -> anyhow::Result<()> { let pts = to_ts_timestamp(unit.timestamp)?; - let stream_id = if unit.is_video { - StreamId::new(StreamId::VIDEO_MIN) - } else { - StreamId::new(StreamId::AUDIO_MIN) + let stream_id = match unit.stream_id { + Some(id) => StreamId::new(id), + None if unit.is_video => StreamId::new(StreamId::VIDEO_MIN), + None => StreamId::new(StreamId::AUDIO_MIN), }; let header = mpeg2ts::pes::PesHeader { stream_id, @@ -610,17 +721,17 @@ impl Export { Ok(()) } - /// Packetize a private section (SCTE-35) verbatim. The first packet carries the - /// pointer_field plus the section start as a `Section` payload (sets the unit- - /// start bit so the receiver finds the pointer_field); continuations are `Raw`. - /// The section bytes are opaque, so this round-trips byte-for-byte. + /// Packetize a private section (SCTE-35 or other) verbatim. The first packet + /// carries the pointer_field plus the section start as a `Section` payload (sets + /// the unit-start bit so the receiver finds the pointer_field); continuations are + /// `Raw`. The section bytes are opaque, so this round-trips byte-for-byte. fn write_section(&mut self, out: &mut Vec, pid: u16, section: &[u8]) -> anyhow::Result<()> { - // The .scte35 track is public; a non-importer producer could publish a frame - // that isn't a complete splice_info_section. Drop it (with a warning) rather - // than emit a malformed section a downstream demuxer would choke on. One bad - // cue must not abort a live export, so this skips instead of erroring. - if !is_complete_scte35_section(section) { - tracing::warn!(pid, len = section.len(), "dropping malformed SCTE-35 section on export"); + // The verbatim track is public; a non-importer producer could publish a frame + // that isn't a complete section. Drop it (with a warning) rather than emit a + // malformed section a downstream demuxer would choke on. One bad section must + // not abort a live export, so this skips instead of erroring. + if !is_complete_section(section) { + tracing::warn!(pid, len = section.len(), "dropping malformed private section on export"); return Ok(()); } @@ -756,18 +867,31 @@ fn audio_kind(config: &AudioConfig, name: &str) -> anyhow::Result { } } -fn scte35_kind(config: &scte35::Config, name: &str) -> anyhow::Result { - ensure_raw(&config.container, "scte35", name)?; - Ok(Kind::Scte35) +/// The PMT descriptors recorded for `name` in the `mpegts` section, if any. +fn track_descriptors(mpegts: &catalog::Mpegts, name: &str) -> Vec { + mpegts + .tracks + .get(name) + .map(|t| t.descriptors.clone()) + .unwrap_or_default() +} + +/// Convert catalog descriptors (base64 bytes) to mpeg2ts PMT descriptors. +fn to_pmt_descriptors(descriptors: &[catalog::Descriptor]) -> Vec { + descriptors + .iter() + .map(|d| Descriptor { + tag: d.tag, + data: d.data.to_vec(), + }) + .collect() } -/// One SCTE-35 frame must be exactly one splice_info_section: table_id 0xFC and a -/// total length matching the declared section_length. Structural only (no splice -/// semantics); the bytes are still carried verbatim. -fn is_complete_scte35_section(section: &[u8]) -> bool { - section.len() >= 3 - && section[0] == 0xfc - && section.len() == 3 + ((((section[1] & 0x0f) as usize) << 8) | section[2] as usize) +/// One section-framed verbatim frame must be exactly one section: at least the +/// 3-byte header and a total length matching the declared section_length. +/// Structural only (no table semantics); the bytes are still carried verbatim. +fn is_complete_section(section: &[u8]) -> bool { + section.len() >= 3 && section.len() == 3 + ((((section[1] & 0x0f) as usize) << 8) | section[2] as usize) } fn ensure_raw(container: &Container, kind: &str, name: &str) -> anyhow::Result<()> { @@ -780,22 +904,22 @@ fn ensure_raw(container: &Container, kind: &str, name: &str) -> anyhow::Result<( #[cfg(test)] mod tests { - use super::is_complete_scte35_section; + use super::is_complete_section; #[test] - fn scte35_section_validation() { - // table_id 0xFC, section_length 27 (0x1b) -> 30 bytes total. + fn section_validation() { + // section_length 27 (0x1b) -> 30 bytes total. let mut ok = vec![0xfc, 0x30, 0x1b]; ok.resize(30, 0x00); - assert!(is_complete_scte35_section(&ok)); + assert!(is_complete_section(&ok)); // minimal: section_length 0 -> exactly the 3-byte header. - assert!(is_complete_scte35_section(&[0xfc, 0x00, 0x00])); + assert!(is_complete_section(&[0xfc, 0x00, 0x00])); + // any table_id is accepted (verbatim carriage isn't SCTE-specific). + assert!(is_complete_section(&[0x00, 0x00, 0x00])); // shorter than the 3-byte header. - assert!(!is_complete_scte35_section(&[0xfc, 0x00])); - // wrong table_id (not a splice_info_section). - assert!(!is_complete_scte35_section(&[0x00, 0x00, 0x00])); + assert!(!is_complete_section(&[0xfc, 0x00])); // declared section_length (27) does not match the actual length (3). - assert!(!is_complete_scte35_section(&[0xfc, 0x30, 0x1b])); + assert!(!is_complete_section(&[0xfc, 0x30, 0x1b])); } } diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 9fb90837b..a184a336a 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -15,7 +15,7 @@ use mpeg2ts::pes::{PesPacketReader, ReadPesPacket}; use mpeg2ts::ts::{ReadTsPacket, TsPacketReader, TsPayload}; use crate::catalog::hang::Container as HangContainer; -use crate::container::ts::{Export, scte35}; +use crate::container::ts::{Export, catalog as tscat}; use crate::container::{Frame, Producer}; use moq_net::Timestamp; @@ -23,6 +23,8 @@ const SC: &[u8] = &[0, 0, 0, 1]; // Reusable H.264 parameter-set and slice NALs (NAL type = first byte & 0x1f). const SPS: &[u8] = &[0x67, 0x42, 0xc0, 0x1f, 0xde]; const PPS: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; +// A second, distinct PPS (id 1): broadcast feeds often define more than one. +const PPS1: &[u8] = &[0x68, 0xce, 0x3c, 0x81]; // libklvanc public-sample SCTE-35 cue: splice_info_section, table_id 0xFC, 30 bytes. const CUE: &[u8] = &[ @@ -62,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 { @@ -252,6 +254,56 @@ async fn export_avc3_in_band_reassembles() { assert_eq!(reassembled.as_slice(), annexb(&[SPS, PPS, &idr]).as_ref()); } +/// In-band avc3 carrying two distinct PPS (a real broadcast trait): both must +/// survive the round-trip, or slices referencing the dropped one stop decoding +/// (regression for non-existing PPS 0 referenced). +#[tokio::test(start_paused = true)] +async fn export_avc3_preserves_multiple_pps() { + let mut broadcast = moq_net::BroadcastInfo::new().produce(); + let consumer = broadcast.consume(); + let mut catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + + let track = broadcast + .create_track( + broadcast.unique_name(".avc3"), + moq_net::TrackInfo::default().with_timescale(hang::container::TIMESCALE), + ) + .unwrap(); + let name = track.name().to_string(); + { + let mut cfg = VideoConfig::new(H264 { + profile: 0x64, + constraints: 0, + level: 0x1f, + inline: true, + }); + cfg.container = Container::Legacy; + catalog.lock().video.renditions.insert(name.clone(), cfg); + } + let mut producer = Producer::new(track, HangContainer::Legacy); + + let mut idr = vec![0x65u8]; + idr.extend(std::iter::repeat_n(0xAB, 300)); + // Annex-B keyframe: inline SPS + both PPS + IDR. + producer + .write(Frame { + timestamp: Timestamp::from_millis(0).unwrap(), + duration: None, + payload: annexb(&[SPS, PPS, PPS1, &idr]), + keyframe: true, + }) + .unwrap(); + producer.finish().unwrap(); + + // Keep the producers alive (see `export_aac_roundtrip`). + let ts = drain(consumer).await; + assert_packet_aligned(&ts); + + let reassembled = reassemble_video(&ts, StreamType::H264); + // Both PPS must be re-injected on the keyframe, in order, ahead of the slice. + assert_eq!(reassembled.as_slice(), annexb(&[SPS, PPS, PPS1, &idr]).as_ref()); +} + /// Out-of-band avc1 (e.g. from fmp4 import): length-prefixed NALs with the /// SPS/PPS only in the catalog `description` (avcC). The muxer must parse the /// avcC, prepend the parameter sets as Annex-B on the keyframe, and rewrite the @@ -262,7 +314,7 @@ async fn export_avc1_out_of_band_reassembles() { let consumer = broadcast.consume(); let mut catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); - let avcc = crate::codec::h264::build_avcc(SPS, PPS).unwrap(); + let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap(); let track = broadcast .create_track( @@ -318,10 +370,10 @@ async fn export_scte35_roundtrip() { let mut broadcast = moq_net::BroadcastInfo::new().produce(); let consumer = broadcast.consume(); let mut catalog = - crate::catalog::Producer::with_catalog(&mut broadcast, crate::catalog::hang::Catalog::::default()) + crate::catalog::Producer::with_catalog(&mut broadcast, crate::catalog::hang::Catalog::::default()) .unwrap(); - // Create and write the .scte35 cue track BEFORE moving `broadcast` into + // Create and write the SCTE-35 cue track BEFORE moving `broadcast` into // `Import` (which consumes it); the producer stays alive so the exporter can // subscribe to the retained track. let scte = broadcast @@ -332,9 +384,12 @@ async fn export_scte35_roundtrip() { .unwrap(); let scte_name = scte.name().to_string(); { - let mut cfg = scte35::Config::new(); - cfg.container = Container::Legacy; - catalog.lock().scte35.renditions.insert(scte_name.clone(), cfg); + let track = tscat::Track { + pid: 0x102, + descriptors: Vec::new(), + verbatim: Some(tscat::Verbatim::new(0x86, tscat::Framing::Section)), + }; + catalog.lock().mpegts.tracks.insert(scte_name.clone(), track); } let mut scte_producer = Producer::new(scte, HangContainer::Legacy); scte_producer @@ -354,9 +409,9 @@ async fn export_scte35_roundtrip() { import.finish().unwrap(); // `import`, `catalog`, and `scte_producer` stay alive: retained tracks. The - // exporter must carry the extension to see the scte35 section. + // exporter must carry the extension to see the mpegts section. let ts = drain_with( - Export::with_scte35(consumer, crate::catalog::CatalogFormat::Hang) + Export::with_ts(consumer, crate::catalog::CatalogFormat::Hang) .await .unwrap(), ) @@ -388,20 +443,19 @@ async fn export_scte35_roundtrip() { // Re-import the exported TS and read the .scte35 frame back. let mut broadcast2 = moq_net::BroadcastInfo::new().produce(); let consumer2 = broadcast2.consume(); - let catalog2 = crate::catalog::Producer::with_catalog( - &mut broadcast2, - crate::catalog::hang::Catalog::::default(), - ) - .unwrap(); + let catalog2 = + 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(&BytesMut::from(ts.as_ref())).unwrap(); import2.finish().unwrap(); let snapshot = catalog2.snapshot(); - assert_eq!(snapshot.scte35.renditions.len(), 1, "round-trip lost the SCTE-35 track"); - let name = snapshot.scte35.renditions.keys().next().unwrap(); + let verbatim = snapshot.mpegts.tracks.values().filter(|t| t.verbatim.is_some()).count(); + assert_eq!(verbatim, 1, "round-trip lost the SCTE-35 track"); + let name = scte_track(&snapshot).expect("a scte35 track"); - let track = consumer2.track(name).unwrap().subscribe(None).unwrap().await.unwrap(); + let track = consumer2.track(&name).unwrap().subscribe(None).unwrap().await.unwrap(); let mut scte_reader = crate::container::Consumer::new(track, HangContainer::Legacy); let frame = scte_reader .read() @@ -422,10 +476,10 @@ async fn scte35_without_video_export_is_rejected() { let mut broadcast = moq_net::BroadcastInfo::new().produce(); let consumer = broadcast.consume(); let mut catalog = - crate::catalog::Producer::with_catalog(&mut broadcast, crate::catalog::hang::Catalog::::default()) + crate::catalog::Producer::with_catalog(&mut broadcast, crate::catalog::hang::Catalog::::default()) .unwrap(); - // A scte35 cue track and nothing else. + // A SCTE-35 cue track and nothing else. let scte = broadcast .unique_track( ".scte35", @@ -434,9 +488,12 @@ async fn scte35_without_video_export_is_rejected() { .unwrap(); let scte_name = scte.name().to_string(); { - let mut cfg = scte35::Config::new(); - cfg.container = Container::Legacy; - catalog.lock().scte35.renditions.insert(scte_name, cfg); + let track = tscat::Track { + pid: 0x102, + descriptors: Vec::new(), + verbatim: Some(tscat::Verbatim::new(0x86, tscat::Framing::Section)), + }; + catalog.lock().mpegts.tracks.insert(scte_name, track); } let mut producer = Producer::new(scte, HangContainer::Legacy); producer @@ -450,7 +507,7 @@ async fn scte35_without_video_export_is_rejected() { producer.finish_group().unwrap(); producer.finish().unwrap(); - let mut exporter = Export::with_scte35(consumer, crate::catalog::CatalogFormat::Hang) + let mut exporter = Export::with_ts(consumer, crate::catalog::CatalogFormat::Hang) .await .unwrap(); let err = loop { @@ -765,6 +822,16 @@ async fn kyrion_ac3_mp2_roundtrip_byte_exact() { assert_eq!(roundtripped, ingested, "both audio streams survive byte-for-byte"); } +/// Find the SCTE-35 verbatim stream (stream_type 0x86) in a catalog snapshot. A +/// clip may carry other undecoded streams verbatim, so select by type, not order. +fn scte_track(snap: &crate::catalog::hang::Catalog) -> Option { + snap.mpegts + .tracks + .iter() + .find(|(_, t)| t.verbatim.as_ref().is_some_and(|v| v.stream_type == 0x86)) + .map(|(name, _)| name.clone()) +} + /// Subscribe to a cue track and read every retained `splice_info_section` it holds. async fn read_cues(consumer: &moq_net::BroadcastConsumer, name: &str) -> Vec<(Vec, Timestamp)> { let track = consumer.track(name).unwrap().subscribe(None).unwrap().await.unwrap(); @@ -837,7 +904,7 @@ async fn scte35_fixtures_survive_roundtrip() { let consumer = broadcast.consume(); let catalog = crate::catalog::Producer::with_catalog( &mut broadcast, - crate::catalog::hang::Catalog::::default(), + crate::catalog::hang::Catalog::::default(), ) .unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); @@ -846,7 +913,9 @@ async fn scte35_fixtures_survive_roundtrip() { let snap = catalog.snapshot(); assert!(!snap.video.renditions.is_empty(), "{source}: video track from the clip"); - let name = snap.scte35.renditions.keys().next().expect("a scte35 track").clone(); + // Select the SCTE-35 stream by stream_type (0x86); a clip may also carry other + // undecoded streams verbatim (e.g. Opus as private PES in bbb5s). + let name = scte_track(&snap).expect("a scte35 track"); let ingested = read_cues(&consumer, &name).await; assert_eq!(ingested.len(), *total, "{source}: {total} cues on ingest"); assert!( @@ -880,7 +949,7 @@ async fn scte35_fixtures_survive_roundtrip() { // Export and re-ingest. let ts = drain_with( - Export::with_scte35(consumer, crate::catalog::CatalogFormat::Hang) + Export::with_ts(consumer, crate::catalog::CatalogFormat::Hang) .await .unwrap(), ) @@ -891,20 +960,13 @@ async fn scte35_fixtures_survive_roundtrip() { let consumer2 = broadcast2.consume(); let catalog2 = crate::catalog::Producer::with_catalog( &mut broadcast2, - crate::catalog::hang::Catalog::::default(), + crate::catalog::hang::Catalog::::default(), ) .unwrap(); let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.clone()); import2.decode(&BytesMut::from(ts.as_ref())).unwrap(); import2.finish().unwrap(); - let name2 = catalog2 - .snapshot() - .scte35 - .renditions - .keys() - .next() - .expect("a scte35 track") - .clone(); + let name2 = scte_track(&catalog2.snapshot()).expect("a scte35 track"); let roundtripped = read_cues(&consumer2, &name2).await; let before: Vec<&Vec> = ingested.iter().map(|(b, _)| b).collect(); diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index a2b069bcd..27cd9b08c 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -2,12 +2,13 @@ //! //! [`Import`] reads a TS byte stream, reassembles PES packets per PID, and //! routes their payloads to the codec importers (H.264/H.265/AAC, plus the -//! legacy MP2/AC-3/E-AC-3 verbatim path), -//! which own their broadcast tracks and catalog entries. SCTE-35 rides in private -//! sections (not PES), so those PIDs are intercepted before the mpeg2ts reader -//! and reassembled onto a typed scte35 catalog section. TS adds PAT/PMT -//! discovery, PES reassembly, the SCTE-35 section path, and the 90 kHz -> -//! microsecond PTS conversion. +//! legacy MP2/AC-3/E-AC-3 verbatim path), which own their broadcast tracks and +//! catalog entries. Elementary streams we don't decode are carried verbatim, one +//! MoQ track per PID, described in the `mpegts` catalog section: PES-framed streams +//! ride the normal PES reassembly, while section-framed streams (SCTE-35 and +//! other private sections, which are not PES) are intercepted before the mpeg2ts +//! reader and reassembled. TS adds PAT/PMT discovery, PES reassembly, the +//! private-section path, and the 90 kHz -> microsecond PTS conversion. use std::collections::{HashMap, HashSet}; use std::io::Read; @@ -20,7 +21,7 @@ use mpeg2ts::ts::payload::Pes; use mpeg2ts::ts::{Pid, ReadTsPacket, TsPacket, TsPacketReader, TsPayload}; use super::adts; -use super::scte35; +use super::catalog; use crate::catalog::hang::CatalogExt; use crate::codec::{aac, ac3, eac3, h264, h265, legacy, mp2}; use moq_net::Timestamp; @@ -29,12 +30,16 @@ use moq_net::Timestamp; /// /// Supports H.264 (stream type 0x1B), H.265 (0x24), ADTS AAC (0x0F), MP2 /// (0x03/0x04), AC-3 (0x81), and E-AC-3 (0x87). LATM/LOAS AAC (0x11) is not -/// ADTS-framed and is dropped. SCTE-35 (private sections marked -/// by a program-level 'CUEI' registration descriptor) is intercepted before the -/// reader and reassembled. Other elementary streams are logged and dropped. Each -/// codec stream is fed to its importer, which manages the track, catalog config, -/// and keyframe-based group boundaries. -pub struct Import { +/// ADTS-framed and is dropped. Each codec stream is fed to its importer, which +/// manages the track, catalog config, and keyframe-based group boundaries. +/// +/// Elementary streams we don't decode are carried verbatim, one MoQ track per +/// PID, when the catalog `E` carries the [`mpegts`](catalog) section: PES-framed +/// streams ride the normal PES reassembly, section-framed streams (SCTE-35, marked +/// 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 { broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer, @@ -58,9 +63,9 @@ pub struct Import { /// the audio catalog jitter (see [`AacStream::write`]). Reset on a video frame. audio_burst: Option, - /// Whole-packet accumulator. Bytes are routed one TS packet at a time (SCTE - /// PIDs diverted, the rest fed to the reader); a trailing partial packet is - /// kept here for the next call. + /// Whole-packet accumulator. Bytes are routed one TS packet at a time + /// (section-framed verbatim PIDs diverted, the rest fed to the reader); a + /// trailing partial packet is kept here for the next call. scratch: Vec, /// Sync lock. 0x47 is the packet sync byte but also occurs freely in payload (TS /// has no byte stuffing), so a lone 0x47 isn't a boundary. False until a candidate @@ -68,14 +73,24 @@ pub struct Import { /// and trust the per-packet check. Persists across `decode` calls so a candidate /// pending confirmation at a buffer tail is re-confirmed, not trusted blindly. synced: bool, - /// SCTE-35 PIDs, intercepted before the reader. SCTE-35 is carried as private - /// sections (table_id 0xFC), not PES, so the reader would `Pes::read_from` and - /// abort. Keyed by PID. Detected via the PMT 'CUEI' registration descriptor. - scte: HashMap>, - /// Whether the catalog can carry the scte35 section, sampled once at construction. - /// A base `Catalog<()>` can't, so its SCTE PIDs route to `Stream::Ignored`. - supports_scte35: bool, - /// Latest video PTS: the media clock used to timestamp SCTE-35 sections, which + /// Section-framed verbatim PIDs, intercepted before the reader. Private sections + /// (SCTE-35 table_id 0xFC and others) are not PES, so the reader would + /// `Pes::read_from` and abort. Keyed by PID. SCTE-35 is detected via the PMT + /// 'CUEI' registration descriptor. + sections: HashMap>, + /// Whether the catalog can carry the `mpegts` section, sampled once at construction. + /// A base `Catalog<()>` can't, so its undecoded PIDs route to `Stream::Ignored`. + supports_mpegts: bool, + /// PMT ES-level descriptors per PID, stashed when a PMT is parsed so a decoded + /// media track can record them (language, registration, ...) once its track exists. + es_descriptors: HashMap>, + /// Decoded media PIDs already recorded into `mpegts.tracks`, so the reconcile in + /// [`Self::flush`] runs once per track rather than on every frame. + recorded_media: HashSet, + /// Whether the PMT program-level descriptors have been recorded yet (set once; + /// PMT `program_info` is stable for the program's life). + program_recorded: bool, + /// Latest video PTS: the media clock used to timestamp private sections, which /// carry no PES PTS of their own. Unwrapped independently of the video stream. /// SPTS scope: one clock for the whole input. Under MPTS every program's video /// advances it, so a cue could be stamped with another program's PTS. @@ -83,13 +98,13 @@ 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_scte35 = snapshot.scte35_mut().is_some(); + let supports_mpegts = snapshot.mpegts_mut().is_some(); Self { broadcast, catalog, @@ -102,8 +117,11 @@ impl Import { audio_burst: None, scratch: Vec::new(), synced: false, - scte: HashMap::new(), - supports_scte35, + sections: HashMap::new(), + supports_mpegts, + es_descriptors: HashMap::new(), + recorded_media: HashSet::new(), + program_recorded: false, last_pts: None, media_unwrap: PtsUnwrap::default(), } @@ -115,10 +133,11 @@ impl Import { pub fn decode(&mut self, data: &[u8]) -> anyhow::Result<()> { self.scratch.extend_from_slice(data); - // Route one whole packet at a time. SCTE-35 PIDs are intercepted here (the - // reader would PES-parse their sections and abort); every other packet is - // fed to the reader. Per-packet so a PMT is parsed (and any SCTE PID - // registered) before the packets that follow it in the same chunk route. + // Route one whole packet at a time. Section-framed verbatim PIDs are + // intercepted here (the reader would PES-parse their sections and abort); + // every other packet is fed to the reader. Per-packet so a PMT is parsed + // (and any section PID registered) before the packets that follow it in the + // same chunk route. let mut off = 0; while off + TsPacket::SIZE <= self.scratch.len() { // A TS packet starts with the 0x47 sync byte. Once synced we trust it and stride @@ -161,13 +180,14 @@ impl Import { off += TsPacket::SIZE; let pid = (((pkt[1] & 0x1f) as u16) << 8) | pkt[2] as u16; let pts = self.last_pts.unwrap_or(Timestamp::ZERO); - if let Some(scte) = self.scte.get_mut(&pid) { - scte.packet(&pkt, pts)?; + if let Some(section) = self.sections.get_mut(&pid) { + section.packet(&pkt, pts)?; continue; } - // PIDs we don't decode (`Stream::Ignored`: unsupported codecs, or a 0x86 - // section PID without CUEI) are dropped here, not fed to the PES reader, - // which aborts on private sections (spec section 7: never fatal). + // PIDs we don't decode and don't carry (`Stream::Ignored`: a base catalog's + // undecoded streams, or an ambiguous 0x86 PID without CUEI) are dropped here, + // not fed to the PES reader, which aborts on private sections (spec section 7: + // never fatal). if let Ok(p) = Pid::new(pid) && matches!(self.streams.get(&p), Some(Stream::Ignored)) { @@ -206,15 +226,37 @@ impl Import { // format_identifier 'CUEI' (ITU-T J.181). The stream itself uses // stream_type 0x86, which mpeg2ts maps to a DTS audio variant, so // detection keys off the CUEI descriptor, not the stream type alone. - let scte = pmt + let cuei = pmt .program_info .iter() .any(|d| d.tag == 0x05 && d.data.len() >= 4 && &d.data[0..4] == b"CUEI"); + + // Record the program-level descriptors once (PMT program_info is stable); + // 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() { + mpegts.program_descriptors = program; + } + self.program_recorded = true; + } + for es in &pmt.es_info { - if scte && matches!(es.stream_type, StreamType::Dts8ChannelLosslessAudio) { - self.ensure_scte(es.elementary_pid)?; + let stream_type = es.stream_type as u8; + // Stash ES descriptors so a decoded media track can record them once its + // (lazily created) track exists; verbatim streams record their own. + if self.supports_mpegts { + self.es_descriptors + .insert(es.elementary_pid.as_u16(), to_descriptors(&es.descriptors)); + } + // Section-framed private data is intercepted before the reader (which + // aborts on private sections): private sections (0x05) and CUEI-marked + // SCTE-35 (0x86). Everything else routes through ensure_stream (a decoded + // codec, PES-framed verbatim, or dropped). + if stream_type == 0x05 || (cuei && matches!(es.stream_type, StreamType::Dts8ChannelLosslessAudio)) { + self.ensure_section(es.elementary_pid, stream_type, &es.descriptors)?; } else { - self.ensure_stream(es.elementary_pid, es.stream_type)?; + self.ensure_stream(es.elementary_pid, es.stream_type, &es.descriptors)?; } } } @@ -230,7 +272,21 @@ impl Import { Ok(()) } - fn ensure_stream(&mut self, pid: Pid, stream_type: StreamType) -> anyhow::Result<()> { + fn ensure_stream( + &mut self, + pid: Pid, + stream_type: StreamType, + descriptors: &[mpeg2ts::ts::Descriptor], + ) -> anyhow::Result<()> { + // A later PMT can remap a PID that was section-framed (intercepted in + // `decode`) to a PES codec/verbatim stream. Drop the stale section route first, + // or it would keep intercepting the PID and the new stream would never get data. + // This only fires on a genuine remap: section PIDs otherwise route to + // `ensure_section`, never here. + if let Some(mut section) = self.sections.remove(&pid.as_u16()) { + section.finish()?; + self.pending.remove(&pid); + } if self.streams.contains_key(&pid) { return Ok(()); } @@ -268,9 +324,30 @@ impl Import { StreamType::DolbyDigitalUpToSixChannelAudio => self.legacy_stream(&ac3::DESCRIPTOR), StreamType::DolbyDigitalPlusUpTo16ChannelAudioForAtsc => self.legacy_stream(&eac3::DESCRIPTOR), StreamType::Mpeg1Video | StreamType::Mpeg2Video => Stream::Clock, + // A codec we don't decode. Carry it verbatim as PES when the catalog supports + // the `mpegts` section. 0x86 is excluded: it's ambiguous (DTS audio, or a + // non-conformant SCTE-35 mux without CUEI, which is sections the PES reader + // would abort on), so drop it rather than risk feeding sections to the reader. other => { - tracing::warn!(?other, pid = pid.as_u16(), "unsupported TS stream type, dropping"); - Stream::Ignored + if self.supports_mpegts && !matches!(other, StreamType::Dts8ChannelLosslessAudio) { + let descriptors = to_descriptors(descriptors); + match VerbatimStream::new( + self.broadcast.clone(), + self.catalog.clone(), + pid.as_u16(), + stream_type as u8, + descriptors, + ) { + Ok(stream) => Stream::Verbatim(Box::new(stream)), + Err(err) => { + tracing::warn!(?err, pid = pid.as_u16(), "failed to create verbatim stream, dropping"); + Stream::Ignored + } + } + } else { + tracing::warn!(?other, pid = pid.as_u16(), "unsupported TS stream type, dropping"); + Stream::Ignored + } } }; @@ -294,33 +371,47 @@ impl Import { })) } - /// Register a SCTE-35 PID: intercepted (see [`Self::decode`]) with a cue track when - /// the catalog carries the section, dropped as `Ignored` when it can't. - fn ensure_scte(&mut self, pid: Pid) -> anyhow::Result<()> { - if self.scte.contains_key(&pid.as_u16()) { + /// Register a section-framed verbatim PID (SCTE-35 or other private sections): + /// intercepted (see [`Self::decode`]) with a verbatim track when the catalog + /// carries the `mpegts` section, dropped as `Ignored` when it can't. + fn ensure_section( + &mut self, + pid: Pid, + stream_type: u8, + descriptors: &[mpeg2ts::ts::Descriptor], + ) -> anyhow::Result<()> { + if self.sections.contains_key(&pid.as_u16()) { return Ok(()); } - // This PID is becoming SCTE; drop any partial PES a prior codec left pending. + // This PID is becoming section-framed; drop any partial PES a prior codec left pending. self.pending.remove(&pid); - if !self.supports_scte35 { + if !self.supports_mpegts { // Always route to Ignored, replacing any prior codec on this PID (a later PMT // can reassign it), so a private section never reaches the PES reader. Warn once. if !matches!(self.streams.insert(pid, Stream::Ignored), Some(Stream::Ignored)) { tracing::warn!( pid = pid.as_u16(), - "SCTE-35 detected without catalog support; dropping cues" + "private section stream detected without `mpegts` catalog support; dropping" ); } return Ok(()); } - // A pre-CUEI PMT may have routed this PID to Ignored; drop it so the PID has one route. + // A prior PMT may have routed this PID to Ignored; drop it so the PID has one route. self.streams.remove(&pid); - let stream = ScteStream::new(self.broadcast.clone(), self.catalog.clone())?; - self.scte.insert(pid.as_u16(), stream); + let descriptors = to_descriptors(descriptors); + let stream = SectionStream::new( + self.broadcast.clone(), + self.catalog.clone(), + pid.as_u16(), + stream_type, + descriptors, + )?; + self.sections.insert(pid.as_u16(), stream); self.initialized = true; tracing::debug!( pid = pid.as_u16(), - "SCTE-35 stream detected (CUEI); intercepting before the reader" + stream_type, + "private section stream detected; intercepting before the reader" ); Ok(()) } @@ -360,6 +451,7 @@ impl Import { let data_len = pes_data_len(&pes.header, pes.pes_packet_len); let mut pending = Pending { pts: pes.header.pts.map(|t| t.as_u64()), + stream_id: pes.header.stream_id.as_u8(), data: Vec::with_capacity(pes.data.len()), data_len, }; @@ -407,7 +499,39 @@ impl Import { let Some(stream) = self.streams.get_mut(&pid) else { return Ok(()); }; - stream.write(pending, run_start) + stream.write(pending, run_start)?; + + // Record the decoded media track's PID + PMT descriptors (language, ...) once + // its lazily created track exists, so export can preserve them. + self.record_media_track(pid); + Ok(()) + } + + /// Record a decoded media stream's PID and ES descriptors into `mpegts.tracks`, + /// once per track. No-op without the `mpegts` section, before the track exists, + /// or for verbatim streams (which self-register). + fn record_media_track(&mut self, pid: Pid) { + if !self.supports_mpegts || self.recorded_media.contains(&pid) { + return; + } + let (name, descriptors) = { + let Some(name) = self.streams.get(&pid).and_then(|s| s.media_track_name()) else { + return; + }; + ( + name, + self.es_descriptors.get(&pid.as_u16()).cloned().unwrap_or_default(), + ) + }; + if let Some(mpegts) = self.catalog.lock().mpegts_mut() { + let entry = mpegts + .tracks + .entry(name) + .or_insert_with(|| catalog::Track::new(pid.as_u16())); + entry.pid = pid.as_u16(); + entry.descriptors = descriptors; + } + self.recorded_media.insert(pid); } /// Close the current group on every track and reopen at `sequence`. @@ -415,8 +539,8 @@ impl Import { for stream in self.streams.values_mut() { stream.seek(sequence)?; } - for scte in self.scte.values_mut() { - scte.seek(sequence)?; + for section in self.sections.values_mut() { + section.seek(sequence)?; } Ok(()) } @@ -430,8 +554,8 @@ impl Import { for stream in self.streams.values_mut() { stream.finish()?; } - for scte in self.scte.values_mut() { - scte.finish()?; + for section in self.sections.values_mut() { + section.finish()?; } Ok(()) } @@ -441,52 +565,105 @@ impl Import { struct Pending { /// Raw 90 kHz PTS, before wrap-unwrapping. pts: Option, + /// PES stream_id, preserved for verbatim PES carriage. + stream_id: u8, data: Vec, /// Expected payload length for bounded PES, else `None` (unbounded video). data_len: Option, } -/// Publishes reassembled SCTE-35 `splice_info_section`s as frames on a track in -/// the catalog's typed scte35 section. +/// Convert mpeg2ts PMT descriptors to the catalog's verbatim form. +fn to_descriptors(descriptors: &[mpeg2ts::ts::Descriptor]) -> Vec { + descriptors + .iter() + .map(|d| catalog::Descriptor { + tag: d.tag, + data: bytes::Bytes::copy_from_slice(&d.data), + }) + .collect() +} + +/// Create a verbatim track and record it in the `mpegts` catalog section as a +/// [`Track`](catalog::Track) with a `verbatim` carriage record. Shared by the +/// section- and PES-framed paths. +fn register_verbatim( + broadcast: &mut moq_net::BroadcastProducer, + catalog: &mut crate::catalog::Producer, + pid: u16, + stream_type: u8, + 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", + moq_net::TrackInfo::default().with_timescale(hang::container::TIMESCALE), + )?; + + let mut guard = catalog.lock(); + let Some(mpegts) = guard.mpegts_mut() 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"); + }; + mpegts.tracks.insert( + track.name().to_string(), + catalog::Track { + pid, + descriptors, + verbatim: Some(catalog::Verbatim::new(stream_type, framing)), + }, + ); + drop(guard); + + Ok(crate::container::Producer::new( + track, + crate::catalog::hang::Container::Legacy, + )) +} + +/// 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() { + mpegts.tracks.remove(name); + } +} + +/// Publishes reassembled private sections (SCTE-35 and others) as verbatim frames +/// on a track described in the `mpegts` catalog section. /// -/// SCTE-35 rides in private sections (table_id 0xFC), not PES, so this PID is +/// Private sections (e.g. SCTE-35 table_id 0xFC) are not PES, so this PID is /// intercepted before the mpeg2ts reader (which would PES-parse it and abort). -/// The byte-level reassembly lives in [`ScteReassembler`]; this type owns the +/// The byte-level reassembly lives in [`SectionReassembler`]; this type owns the /// track and catalog entry and stamps each section with the media clock. -struct ScteStream { +struct SectionStream { track: crate::container::Producer, catalog: crate::catalog::Producer, - reassembler: ScteReassembler, + reassembler: SectionReassembler, } -impl ScteStream { +impl SectionStream { fn new( mut broadcast: moq_net::BroadcastProducer, mut catalog: crate::catalog::Producer, + pid: u16, + stream_type: u8, + descriptors: Vec, ) -> anyhow::Result { - let mut guard = catalog.lock(); - let Some(scte35) = guard.scte35_mut() else { - // supports_scte35 was true when sampled at construction; None here means - // the catalog dropped the section since. - anyhow::bail!("catalog extension no longer carries a scte35 section"); - }; - - // Cues 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( - ".scte35", - moq_net::TrackInfo::default().with_timescale(hang::container::TIMESCALE), + let track = register_verbatim( + &mut broadcast, + &mut catalog, + pid, + stream_type, + catalog::Framing::Section, + descriptors, )?; - let mut config = scte35::Config::new(); - config.container = hang::catalog::Container::Legacy; - scte35.renditions.insert(track.name().to_string(), config); - drop(guard); - Ok(Self { - track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), + track, catalog, - reassembler: ScteReassembler::default(), + reassembler: SectionReassembler::default(), }) } @@ -526,23 +703,105 @@ impl ScteStream { } } -impl Drop for ScteStream { +impl Drop for SectionStream { fn drop(&mut self) { - if let Some(scte35) = self.catalog.lock().scte35_mut() { - scte35.renditions.remove(self.track.name()); + let name = self.track.name().to_string(); + unregister_verbatim(&mut self.catalog, &name); + } +} + +/// Publishes whole reassembled PES payloads verbatim as frames on a track +/// described in the `mpegts` catalog section, for elementary streams we don't decode +/// (DTS audio, private PES, teletext, ...). +/// +/// 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 { + track: crate::container::Producer, + catalog: crate::catalog::Producer, + unwrap: PtsUnwrap, + /// Whether the PES stream_id has been recorded into the catalog yet (once). + stream_id_recorded: bool, +} + +impl VerbatimStream { + fn new( + mut broadcast: moq_net::BroadcastProducer, + mut catalog: crate::catalog::Producer, + pid: u16, + stream_type: u8, + descriptors: Vec, + ) -> anyhow::Result { + let track = register_verbatim( + &mut broadcast, + &mut catalog, + pid, + stream_type, + catalog::Framing::Pes, + descriptors, + )?; + Ok(Self { + track, + catalog, + unwrap: PtsUnwrap::default(), + stream_id_recorded: false, + }) + } + + /// Publish one reassembled PES payload verbatim, in its own group, stamped with + /// its PTS (or zero when the PES carried none). + fn write(&mut self, pending: Pending) -> anyhow::Result<()> { + // 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().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()) + { + verbatim.stream_id = Some(pending.stream_id); + } + self.stream_id_recorded = true; } + + 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, + }; + self.track.write(frame)?; + self.track.finish_group()?; + Ok(()) + } + + fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { + self.track.seek(sequence)?; + Ok(()) + } + + fn finish(&mut self) -> anyhow::Result<()> { + self.track.finish()?; + Ok(()) + } +} + +impl Drop for VerbatimStream { + fn drop(&mut self) { + let name = self.track.name().to_string(); + unregister_verbatim(&mut self.catalog, &name); } } /// Byte-level reassembler for MPEG-TS private sections on one PID. /// -/// SCTE-35 rides in private sections (table_id 0xFC), not PES. This handles +/// Private sections (SCTE-35 table_id 0xFC and others) are not PES. This handles /// pointer_field alignment, sections split across packets (including a 3-byte /// header split, where section_length is not yet known), continuity-counter /// gaps, and adaptation-field discontinuities. Deliberately private and minimal: -/// just enough to recover whole splice_info_sections. +/// just enough to recover whole sections verbatim. #[derive(Default)] -struct ScteReassembler { +struct SectionReassembler { /// Bytes of the section currently being reassembled. Its 3-byte header (and /// thus section_length) may not all be present yet, so completeness is /// re-checked as bytes arrive; empty means no section in progress. @@ -553,9 +812,8 @@ struct ScteReassembler { last_pkt: Option<[u8; 188]>, } -impl ScteReassembler { - /// Consume one 188-byte TS packet, appending every completed - /// splice_info_section (table_id 0xFC) to `out`. +impl SectionReassembler { + /// Consume one 188-byte TS packet, appending every completed section to `out`. fn push(&mut self, pkt: &[u8], out: &mut Vec>) { // transport_error_indicator: the demodulator flagged this packet as corrupt, // so its payload can't be trusted (and we don't validate CRC-32). Drop it and @@ -653,8 +911,8 @@ impl ScteReassembler { /// Move every complete section out of `acc` into `out`, stopping at the first /// partial. The 3-byte header (which holds section_length) can itself be split /// across TS packets, so a short buffer waits for more bytes rather than being - /// dropped. Only splice_info_sections (table_id 0xFC) are kept; anything else - /// that slipped through PID detection is consumed and discarded. + /// dropped. Every complete section is carried verbatim (SCTE-35 and any other + /// private-section table on the PID); only 0xff stuffing is dropped. fn drain(&mut self, out: &mut Vec>) { loop { match self.acc.first() { @@ -670,9 +928,9 @@ impl ScteReassembler { return; } let section_length = (((self.acc[1] & 0x0f) as usize) << 8) | self.acc[2] as usize; - // SCTE-35 sections are tiny; section_length tops out at 4093 per spec. A - // larger value means we are misparsing garbage, so drop and resync at the - // next pointer_field rather than buffering up to ~4 KB of junk. + // section_length tops out at 4093 per spec (12-bit field, top 2 bits zero). A + // larger value means we are misparsing garbage, so drop and resync at the next + // pointer_field rather than buffering up to ~4 KB of junk. if section_length > 4093 { self.acc.clear(); return; @@ -681,16 +939,13 @@ impl ScteReassembler { if self.acc.len() < full { return; } - let section: Vec = self.acc.drain(..full).collect(); - if section.first() == Some(&0xfc) { - out.push(section); - } + out.push(self.acc.drain(..full).collect()); } } } /// One elementary stream's codec importer plus PTS-unwrap state. -enum Stream { +enum Stream { H264 { split: h264::Split, import: Box>, @@ -703,13 +958,15 @@ enum Stream { }, Aac(Box>), Legacy(Box>), - /// MPEG-1/2 video we don't decode, kept only to advance the SCTE-35 media clock. + /// A codec we don't decode, carried verbatim as PES (DTS audio, private PES, ...). + Verbatim(Box>), + /// MPEG-1/2 video we don't decode, kept only to advance the media clock. /// `is_video` counts it, so never reuse this variant for audio or data. Clock, Ignored, } -impl Stream { +impl Stream { fn write(&mut self, pending: Pending, burst: Option) -> anyhow::Result<()> { match self { Stream::H264 { split, import, unwrap } => { @@ -732,6 +989,7 @@ impl Stream { } Stream::Aac(stream) => stream.write(pending, burst), Stream::Legacy(stream) => stream.write(pending), + Stream::Verbatim(stream) => stream.write(pending), Stream::Clock | Stream::Ignored => Ok(()), } } @@ -748,6 +1006,7 @@ impl Stream { } Stream::Aac(stream) => stream.seek(sequence), Stream::Legacy(stream) => stream.seek(sequence), + Stream::Verbatim(stream) => stream.seek(sequence), Stream::Clock | Stream::Ignored => Ok(()), } } @@ -758,9 +1017,22 @@ impl Stream { Stream::H265 { import, .. } => Ok(import.finish()?), Stream::Aac(stream) => stream.finish(), Stream::Legacy(stream) => stream.finish(), + Stream::Verbatim(stream) => stream.finish(), Stream::Clock | Stream::Ignored => Ok(()), } } + + /// The MoQ track name of a decoded media stream, once its (lazily created) track + /// exists. `None` for verbatim/clock/ignored streams (verbatim self-registers). + fn media_track_name(&self) -> Option { + match self { + 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, + } + } } /// AAC needs the first ADTS header before it can build a [`aac::Import`] @@ -1102,7 +1374,7 @@ impl Read for Feed { mod test { use mpeg2ts::es::StreamType; - use super::ScteReassembler; + use super::SectionReassembler; use moq_net::Timestamp; // libklvanc public-sample cue: table_id 0xFC, section_length 0x1b (27), 30 bytes total. @@ -1146,7 +1418,7 @@ mod test { } fn run(pkts: &[Vec]) -> Vec> { - let mut r = ScteReassembler::default(); + let mut r = SectionReassembler::default(); let mut out = Vec::new(); for p in pkts { r.push(p, &mut out); @@ -1160,12 +1432,13 @@ mod test { } #[test] - fn filters_non_scte() { - // A table_id 0x00 section ahead of the cue: only the 0xFC cue is emitted, and - // the filtered section doesn't desync parsing of what follows it. - let mut body = fake_section(0x00, 5); + fn carries_all_sections_verbatim() { + // A non-SCTE table_id 0x00 section ahead of the cue: both are carried verbatim + // (we no longer filter by table_id), and back-to-back sections parse cleanly. + let other = fake_section(0x00, 5); + let mut body = other.clone(); body.extend_from_slice(&CUE); - assert_eq!(run(&[packet(true, 0, 0, &body)]), vec![CUE.to_vec()]); + assert_eq!(run(&[packet(true, 0, 0, &body)]), vec![other, CUE.to_vec()]); } #[test] @@ -1322,15 +1595,14 @@ 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; - use crate::container::ts::scte35; + use crate::container::ts::catalog::Ext; let mut broadcast = moq_net::BroadcastInfo::new().produce(); - let catalog = - crate::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); + let catalog = crate::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); let mut import = super::Import::new(broadcast, catalog.clone()); let mut bytes = bytes::BytesMut::new(); @@ -1340,7 +1612,7 @@ mod test { import.finish().unwrap(); assert_eq!( - catalog.snapshot().scte35.renditions.len(), + catalog.snapshot().mpegts.tracks.len(), 1, "expected one scte35 rendition" ); @@ -1362,7 +1634,10 @@ mod test { import.decode(&bytes).unwrap(); // must not abort on the private section import.finish().unwrap(); - assert!(import.scte.is_empty(), "no cue stream is created for a base catalog"); + assert!( + import.sections.is_empty(), + "no cue stream is created for a base catalog" + ); assert!( matches!( import.streams.get(&mpeg2ts::ts::Pid::new(0x21).unwrap()), @@ -1387,15 +1662,14 @@ mod test { async fn pmt_without_cuei_then_with_cuei_upgrades() { use crate::catalog::hang::{Catalog, Container}; use crate::container::Consumer; - use crate::container::ts::scte35; + use crate::container::ts::catalog::Ext; const SECTION_PID: u16 = 0x0021; let pid = mpeg2ts::ts::Pid::new(SECTION_PID).unwrap(); let mut broadcast = moq_net::BroadcastInfo::new().produce(); let consumer = broadcast.consume(); - let catalog = - crate::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); + let catalog = crate::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); let mut import = super::Import::new(broadcast, catalog.clone()); // First PMT lacks CUEI: the 0x86 PID is ambiguous and routes to Ignored. @@ -1422,12 +1696,12 @@ mod test { "upgrade drops the stale Ignored route" ); assert_eq!( - catalog.snapshot().scte35.renditions.len(), + catalog.snapshot().mpegts.tracks.len(), 1, "upgrade advertises the cue track" ); - let name = catalog.snapshot().scte35.renditions.keys().next().unwrap().clone(); + let name = catalog.snapshot().mpegts.tracks.keys().next().unwrap().clone(); let track = consumer.track(&name).unwrap().subscribe(None).unwrap().await.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()) @@ -1709,15 +1983,14 @@ mod test { async fn scte35_cue_stamped_with_video_pts() { use crate::catalog::hang::{Catalog, Container}; use crate::container::Consumer; - use crate::container::ts::scte35; + use crate::container::ts::catalog::Ext; use moq_net::Timestamp; const VIDEO_PID: u16 = 0x0050; let mut broadcast = moq_net::BroadcastInfo::new().produce(); let consumer = broadcast.consume(); - let catalog = - crate::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); + let catalog = crate::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); let mut import = super::Import::new(broadcast, catalog.clone()); let mut bytes = bytes::BytesMut::new(); @@ -1734,7 +2007,7 @@ mod test { let clock = import.last_pts.expect("video set the media clock"); import.finish().unwrap(); - let name = catalog.snapshot().scte35.renditions.keys().next().unwrap().clone(); + let name = catalog.snapshot().mpegts.tracks.keys().next().unwrap().clone(); let track = consumer.track(&name).unwrap().subscribe(None).unwrap().await.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()) @@ -1760,16 +2033,15 @@ mod test { #[test] fn section_pid_without_cuei_is_dropped_not_cataloged() { use crate::catalog::hang::Catalog; - use crate::container::ts::scte35; + use crate::container::ts::catalog::Ext; const VIDEO_PID: u16 = 0x0050; const SECTION_PID: u16 = 0x0021; let mut broadcast = moq_net::BroadcastInfo::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 catalog = crate::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); let mut import = super::Import::new(broadcast, catalog.clone()); let mut bytes = bytes::BytesMut::new(); @@ -1790,7 +2062,7 @@ mod test { "video kept importing past the dropped section PID" ); assert!( - catalog.snapshot().scte35.renditions.is_empty(), + catalog.snapshot().mpegts.tracks.is_empty(), "a 0x86 PID without CUEI must not be cataloged" ); } @@ -1825,4 +2097,61 @@ mod test { let p4 = packet(true, 3, 0, &CUE); // clean PUSI: resync and emit assert_eq!(run(&[p1, p2, p3, p4]), vec![CUE.to_vec()]); } + + // A PES-framed elementary stream we don't decode (private data, stream_type 0x06) + // is carried verbatim: cataloged in the `mpegts` section with its PID and framing, and + // its PES payload published byte-for-byte. + #[tokio::test(start_paused = true)] + async fn private_pes_carried_verbatim() { + use crate::catalog::hang::{Catalog, Container}; + use crate::container::Consumer; + use crate::container::ts::catalog::{Ext, Framing}; + + const VIDEO_PID: u16 = 0x0050; + const DATA_PID: u16 = 0x0052; + + let mut broadcast = moq_net::BroadcastInfo::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::with_catalog(&mut broadcast, Catalog::::default()).unwrap(); + let mut import = super::Import::new(broadcast, catalog.clone()); + + let mut bytes = bytes::BytesMut::new(); + bytes.extend_from_slice(&synth_pmt( + &[ + (StreamType::Mpeg2Video, VIDEO_PID), + (StreamType::Mpeg2PacketizedData, DATA_PID), + ], + false, + )); + 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(&bytes).unwrap(); + import.finish().unwrap(); + + let snap = catalog.snapshot(); + assert_eq!(snap.mpegts.tracks.len(), 1, "the private PES PID is carried verbatim"); + let (name, track) = snap.mpegts.tracks.iter().next().unwrap(); + let verbatim = track.verbatim.as_ref().expect("a verbatim carriage record"); + assert_eq!(verbatim.stream_type, 0x06, "recorded the PMT stream_type"); + assert_eq!(verbatim.framing, Framing::Pes, "private PES is PES-framed"); + // `audio_pes_packet` uses stream_id 0xC0; it must be captured for faithful re-emit. + 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 + .track(name.as_str()) + .unwrap() + .subscribe(None) + .unwrap() + .await + .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 + .expect("verbatim read timed out") + .unwrap() + .expect("a published verbatim frame"); + assert_eq!(&frame.payload[..], &payload[..], "verbatim PES payload round-trips"); + } } diff --git a/rs/moq-mux/src/container/ts/import_test.rs b/rs/moq-mux/src/container/ts/import_test.rs index 8f53d10ed..b4477f42f 100644 --- a/rs/moq-mux/src/container/ts/import_test.rs +++ b/rs/moq-mux/src/container/ts/import_test.rs @@ -284,7 +284,7 @@ async fn kyrion_dirtystart_extracts_real_cues() { let consumer = broadcast.consume(); let catalog = crate::catalog::Producer::with_catalog( &mut broadcast, - crate::catalog::hang::Catalog::::default(), + crate::catalog::hang::Catalog::::default(), ) .unwrap(); let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); @@ -295,7 +295,15 @@ async fn kyrion_dirtystart_extracts_real_cues() { let snap = catalog.snapshot(); assert_eq!(snap.video.renditions.len(), 1, "video track lost across the dirty join"); - let name = snap.scte35.renditions.keys().next().expect("scte35 track").clone(); + // Select the SCTE-35 stream by its verbatim stream_type; media tracks also appear + // in mpegts.tracks now (with their PID + descriptors). + let name = snap + .mpegts + .tracks + .iter() + .find(|(_, t)| t.verbatim.as_ref().is_some_and(|v| v.stream_type == 0x86)) + .map(|(name, _)| name.clone()) + .expect("scte35 track"); let track = consumer.track(&name).unwrap().subscribe(None).unwrap().await.unwrap(); let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy); let mut cues = Vec::new(); diff --git a/rs/moq-mux/src/container/ts/mod.rs b/rs/moq-mux/src/container/ts/mod.rs index 1785289e3..35820070f 100644 --- a/rs/moq-mux/src/container/ts/mod.rs +++ b/rs/moq-mux/src/container/ts/mod.rs @@ -5,11 +5,18 @@ //! codec layer (H.264/H.265/AAC, plus the legacy MP2/AC-3/E-AC-3 parsers) does //! the elementary-stream parsing; this module only handles PAT/PMT/PES framing, //! PTS, and ADTS framing for AAC. +//! +//! Elementary streams we don't decode (SCTE-35, teletext, DVB subtitles, private +//! data, ...) are carried verbatim, one MoQ track per PID, described in the +//! [`catalog`] (`mpegts`) section. SCTE-35 is just one such stream (`stream_type` 0x86). mod adts; mod export; mod import; -pub mod scte35; + +/// The `mpegts` catalog section: per-track PID + descriptors plus verbatim +/// carriage of undecoded elementary streams. +pub mod catalog; pub use export::*; pub use import::*; diff --git a/rs/moq-mux/src/container/ts/scte35.rs b/rs/moq-mux/src/container/ts/scte35.rs deleted file mode 100644 index 4caca50ae..000000000 --- a/rs/moq-mux/src/container/ts/scte35.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! SCTE-35 application catalog extension for ingesting MPEG-TS splice cues. - -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; - -use crate::catalog::hang::CatalogExt; - -/// SCTE-35 splice cue tracks: a map of renditions (one per MPEG-TS PID), each -/// carried as the verbatim `splice_info_section` bytes. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -pub struct Cues { - pub renditions: BTreeMap, -} - -impl Cues { - /// Omitted from the catalog when empty, so a broadcast without cues stays byte-identical. - pub fn is_empty(&self) -> bool { - self.renditions.is_empty() - } -} - -/// One SCTE-35 cue track. Records how the verbatim section was framed; the -/// stream_type (0x86) and CUEI signaling are implicit to SCTE-35. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct Config { - #[serde(default)] - pub container: hang::catalog::Container, -} - -impl Config { - pub fn new() -> Self { - Self::default() - } -} - -/// The application catalog extension carrying the `scte35` section. Empty by -/// default, so the section is omitted until a cue track is added. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] -pub struct Ext { - #[serde(default, skip_serializing_if = "Cues::is_empty")] - pub scte35: Cues, -} - -impl CatalogExt for Ext {} - -/// An extension that can carry an SCTE-35 catalog section. -/// -/// Implement this for an application extension to compose SCTE-35 with -/// additional sections. -pub trait Catalog: CatalogExt { - /// The section to write cues 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 cues or fail. - fn scte35_mut(&mut self) -> Option<&mut Cues>; -} - -impl Catalog for () { - fn scte35_mut(&mut self) -> Option<&mut Cues> { - None - } -} - -impl Catalog for Ext { - fn scte35_mut(&mut self) -> Option<&mut Cues> { - Some(&mut self.scte35) - } -} diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index 8c3b4146b..ab023487d 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -16,7 +16,7 @@ categories = ["multimedia", "network-programming", "web-programming"] doctest = false [features] -default = ["quinn", "aws-lc-rs", "websocket"] +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"] @@ -26,6 +26,11 @@ aws-lc-rs = ["rustls/aws-lc-rs", "rcgen?/aws_lc_rs", "quinn?/rustls-aws-lc-rs"] iroh = ["dep:web-transport-iroh", "dep:web-transport-proto"] jemalloc = ["dep:tikv-jemallocator", "dep:tikv-jemalloc-ctl"] websocket = ["dep:qmux"] +# Plain-TCP qmux transport (`tcp://`), no TLS. Server-side and client-side. +tcp = ["dep:qmux"] +# Unix-domain-socket qmux transport (`unix://`), unix-only. Adds peer-credential +# inspection on accept. Pulls in `tcp` (shares qmux's stream transport). +uds = ["tcp", "qmux/uds"] ring = ["rustls/ring", "rcgen?/ring", "quinn?/rustls-ring"] android-logcat = ["dep:tracing-android"] @@ -39,7 +44,7 @@ humantime-serde = "1.1" moq-net = { workspace = true, features = ["serde"] } notify = { version = "8", optional = true } parking_lot = { version = "0.12", features = ["deadlock_detection"] } -qmux = { workspace = true, features = ["wss", "tls"], optional = true } +qmux = { workspace = true, features = ["wss", "tls", "tcp"], optional = true } quinn = { version = "0.11", default-features = false, features = ["platform-verifier", "runtime-tokio", "bloom"], optional = true } rand = "0.10.1" rcgen = { version = "0.14", default-features = false, optional = true } diff --git a/rs/moq-native/src/client.rs b/rs/moq-native/src/client.rs index 71184cce4..52d4f2e9b 100644 --- a/rs/moq-native/src/client.rs +++ b/rs/moq-native/src/client.rs @@ -111,15 +111,29 @@ pub struct Client { } impl Client { - #[cfg(not(any(feature = "noq", feature = "quinn", feature = "quiche", feature = "websocket")))] + #[cfg(not(any( + feature = "noq", + feature = "quinn", + feature = "quiche", + feature = "websocket", + feature = "tcp", + feature = "uds" + )))] pub fn new(_config: ClientConfig) -> crate::Result { Err(Error::NoBackend( - "no QUIC or WebSocket backend compiled; enable noq, quinn, quiche, or websocket feature", + "no QUIC or WebSocket backend compiled; enable noq, quinn, quiche, websocket, tcp, or uds feature", )) } /// Create a new client - #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche", feature = "websocket"))] + #[cfg(any( + feature = "noq", + feature = "quinn", + feature = "quiche", + feature = "websocket", + feature = "tcp", + feature = "uds" + ))] pub fn new(config: ClientConfig) -> crate::Result { #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] let backend = config.backend.clone().unwrap_or({ @@ -239,11 +253,13 @@ impl Client { feature = "quinn", feature = "quiche", feature = "iroh", - feature = "websocket" + feature = "websocket", + feature = "tcp", + feature = "uds" )))] pub async fn connect(&self, _url: Url) -> crate::Result { Err(Error::NoBackend( - "no backend compiled; enable noq, quinn, quiche, iroh, or websocket feature", + "no backend compiled; enable noq, quinn, quiche, iroh, websocket, tcp, or uds feature", )) } @@ -252,7 +268,9 @@ impl Client { feature = "quinn", feature = "quiche", feature = "iroh", - feature = "websocket" + feature = "websocket", + feature = "tcp", + feature = "uds" ))] pub async fn connect(&self, url: Url) -> crate::Result { let session = self.connect_inner(url).await?; @@ -265,9 +283,27 @@ impl Client { feature = "quinn", feature = "quiche", feature = "iroh", - feature = "websocket" + feature = "websocket", + feature = "tcp", + feature = "uds" ))] async fn connect_inner(&self, url: Url) -> crate::Result { + // Plain TCP (qmux, no TLS). Explicit opt-in scheme; never raced against + // QUIC, which can't speak it. Use only on a trusted network. + #[cfg(feature = "tcp")] + if url.scheme() == "tcp" { + let session = crate::tcp::connect(url, &self.versions.alpns()).await?; + return Ok(self.moq.connect(session).await?); + } + + // Unix domain socket (qmux, no TLS). Same-host only; the server can + // authenticate us by uid/gid via SO_PEERCRED. + #[cfg(all(feature = "uds", unix))] + if url.scheme() == "unix" { + let session = crate::unix::connect(url, &self.versions.alpns()).await?; + return Ok(self.moq.connect(session).await?); + } + #[cfg(feature = "iroh")] if url.scheme() == "iroh" { let endpoint = self.iroh.as_ref().ok_or(Error::IrohDisabled)?; diff --git a/rs/moq-native/src/error.rs b/rs/moq-native/src/error.rs index 9189cf041..e3cb97a87 100644 --- a/rs/moq-native/src/error.rs +++ b/rs/moq-native/src/error.rs @@ -71,6 +71,14 @@ pub enum Error { #[cfg(feature = "websocket")] #[error(transparent)] WebSocket(Arc), + + #[cfg(feature = "tcp")] + #[error(transparent)] + Tcp(Arc), + + #[cfg(all(feature = "uds", unix))] + #[error(transparent)] + Unix(Arc), } impl Error { @@ -168,6 +176,20 @@ impl From for Error { } } +#[cfg(feature = "tcp")] +impl From for Error { + fn from(err: crate::tcp::Error) -> Self { + Self::Tcp(Arc::new(err)) + } +} + +#[cfg(all(feature = "uds", unix))] +impl From for Error { + fn from(err: crate::unix::Error) -> Self { + Self::Unix(Arc::new(err)) + } +} + /// Convenience alias for results produced by this crate. pub type Result = std::result::Result; diff --git a/rs/moq-native/src/lib.rs b/rs/moq-native/src/lib.rs index 5e4b49675..96717cfcb 100644 --- a/rs/moq-native/src/lib.rs +++ b/rs/moq-native/src/lib.rs @@ -4,6 +4,8 @@ //! - WebTransport (HTTP/3) //! - Raw QUIC (with ALPN negotiation) //! - WebSocket (fallback via [web-transport-ws](https://crates.io/crates/web-transport-ws)) +//! - Plain TCP via the `tcp://` scheme (qmux, no TLS; requires `tcp` feature) +//! - Unix domain socket via the `unix://` scheme (qmux, peer-credential aware; requires `uds` feature, unix-only) //! - Iroh P2P (requires `iroh` feature) //! //! See [`Client`] for connecting to relays and [`Server`] for accepting connections. @@ -25,7 +27,11 @@ pub mod noq; pub mod quinn; mod reconnect; mod server; +#[cfg(feature = "tcp")] +pub mod tcp; pub mod tls; +#[cfg(all(feature = "uds", unix))] +pub mod unix; mod util; #[cfg(feature = "watch")] pub mod watch; diff --git a/rs/moq-native/src/tcp.rs b/rs/moq-native/src/tcp.rs new file mode 100644 index 000000000..381f484df --- /dev/null +++ b/rs/moq-native/src/tcp.rs @@ -0,0 +1,111 @@ +//! Plain-TCP qmux transport, reachable via the `tcp://` URL scheme. +//! +//! Runs the QMux wire format directly over TCP with no TLS or WebSocket +//! framing. There is no transport encryption and no authentication, so only +//! use this on a trusted network (loopback, a private VPC interface, etc.). +//! +//! TCP has no TLS handshake, so the application protocol (the moq ALPN) is +//! negotiated in-band: pass the offered/supported protocols and the resulting +//! `qmux::Session::protocol()` is populated before connect/accept returns. + +use std::net; +use url::Url; + +/// The QMux wire-format version both ends speak over a raw stream. Fixed (not +/// negotiated) since there's no TLS ALPN to carry it. +const WIRE_VERSION: qmux::Version = qmux::Version::QMux01; + +/// Errors specific to the plain-TCP qmux transport. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The TCP socket failed to bind, accept, or connect. + #[error(transparent)] + Io(#[from] std::io::Error), + + /// The `tcp://` URL had no host. + #[error("missing hostname")] + MissingHostname, + + /// The `tcp://` URL had no port. Unlike `https`, there is no default. + #[error("missing port")] + MissingPort, + + /// The qmux handshake failed while dialing. + #[error("qmux connect failed")] + Connect(#[source] qmux::Error), + + /// The qmux handshake failed while accepting. + #[error("qmux accept failed")] + Accept(#[source] qmux::Error), +} + +type Result = std::result::Result; + +/// Dial a `tcp://host:port` URL, advertising `protocols` for in-band ALPN +/// negotiation. Returns a qmux session over plain TCP. +/// +/// The port is required; there is no default for the `tcp` scheme. +pub(crate) async fn connect(url: Url, protocols: &[&str]) -> Result { + let host = url.host_str().ok_or(Error::MissingHostname)?; + let port = url.port().ok_or(Error::MissingPort)?; + + tracing::debug!(%url, "connecting via TCP"); + qmux::tcp::Config::new(WIRE_VERSION) + .protocols(protocols.iter().copied()) + .connect((host, port)) + .await + .map_err(Error::Connect) +} + +/// Listens for incoming plain-TCP qmux connections on a TCP port. +pub struct Listener { + listener: tokio::net::TcpListener, + protocols: Vec, +} + +impl Listener { + /// Bind a TCP listener to the given address. + pub async fn bind(addr: net::SocketAddr) -> Result { + let listener = tokio::net::TcpListener::bind(addr).await?; + Ok(Self { + listener, + protocols: Vec::new(), + }) + } + + /// Advertise these application protocols (moq ALPNs) for in-band negotiation, + /// in preference order. The first server entry the client also offers wins. + pub fn with_protocols(mut self, protocols: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.protocols = protocols.into_iter().map(Into::into).collect(); + self + } + + /// The local address the listener is bound to. + pub fn local_addr(&self) -> Result { + Ok(self.listener.local_addr()?) + } + + /// Accept the next connection, performing the qmux handshake over plain TCP. + /// + /// Returns `None` only if the listener itself is gone; a per-connection + /// failure is yielded as `Some(Err(..))` so the accept loop keeps running. + pub async fn accept(&self) -> Option> { + match self.listener.accept().await { + Ok((stream, addr)) => { + tracing::debug!(%addr, "accepted TCP connection"); + let session = qmux::tcp::Config::new(WIRE_VERSION) + .protocols(self.protocols.iter().map(String::as_str)) + .accept(stream) + .await + .map_err(Error::Accept); + Some(session) + } + Err(e) => Some(Err(e.into())), + } + } +} diff --git a/rs/moq-native/src/unix.rs b/rs/moq-native/src/unix.rs new file mode 100644 index 000000000..c9c74a7f9 --- /dev/null +++ b/rs/moq-native/src/unix.rs @@ -0,0 +1,175 @@ +//! Unix-domain-socket qmux transport, reachable via the `unix://` URL scheme. +//! +//! Runs the QMux wire format over an `AF_UNIX` stream. Unlike the `tcp://` +//! transport, the kernel reports the connecting process's credentials +//! (`SO_PEERCRED` / `LOCAL_PEERCRED`), so a server can authenticate the peer's +//! uid/gid/pid without a shared secret. Unix-only. + +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use url::Url; + +/// The QMux wire-format version both ends speak. Fixed (not negotiated) since a +/// raw stream has no TLS ALPN to carry it. +const WIRE_VERSION: qmux::Version = qmux::Version::QMux01; + +/// Errors specific to the Unix-domain-socket qmux transport. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The socket failed to bind, accept, connect, or chmod. + #[error(transparent)] + Io(#[from] io::Error), + + /// The `unix://` URL had no socket path. + #[error("missing socket path in unix:// URL")] + MissingPath, + + /// The qmux handshake failed while dialing. + #[error("qmux connect failed")] + Connect(#[source] qmux::Error), + + /// The qmux handshake failed while accepting. + #[error("qmux accept failed")] + Accept(#[source] qmux::Error), + + /// The bind path already exists and is not a socket, so we refuse to unlink it. + #[error("refusing to replace existing non-socket file at {0}")] + NotASocket(PathBuf), +} + +type Result = std::result::Result; + +/// Credentials of a connected Unix-socket peer. +/// +/// `pid` is `None` on platforms that don't report it (e.g. some macOS versions); +/// `uid`/`gid` are always available. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PeerCred { + /// The peer process's effective user ID. + pub uid: u32, + /// The peer process's effective group ID. + pub gid: u32, + /// The peer process's PID, if the platform reports it. + pub pid: Option, +} + +/// Dial a `unix://` URL, advertising `protocols` for in-band ALPN +/// negotiation. Returns a qmux session over the socket. +/// +/// The path is taken from the URL path, so use a triple slash for an absolute +/// path: `unix:///run/moq/internal.sock`. +pub(crate) async fn connect(url: Url, protocols: &[&str]) -> Result { + let path = socket_path(&url).ok_or(Error::MissingPath)?; + tracing::debug!(%url, "connecting via Unix socket"); + qmux::uds::Config::new(WIRE_VERSION) + .protocols(protocols.iter().copied()) + .connect(path) + .await + .map_err(Error::Connect) +} + +fn socket_path(url: &Url) -> Option { + let path = url.path(); + if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +/// Listens for incoming qmux connections on a Unix domain socket. +/// +/// Each accepted connection yields the session plus the peer's [`PeerCred`], so +/// the caller can enforce a uid/gid/pid allowlist. The socket file is removed on +/// drop. +pub struct Listener { + listener: tokio::net::UnixListener, + path: PathBuf, + protocols: Vec, +} + +impl Listener { + /// Bind a Unix socket at `path`, replacing a stale socket file left by a + /// previous run. + /// + /// Refuses to unlink the path if it exists and is not a socket, to avoid + /// clobbering an unrelated file. + pub async fn bind(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + + // A leftover socket from a crashed run would make bind() fail with + // EADDRINUSE, so unlink it first. Anything that isn't a socket we leave + // alone and error out. + match fs::symlink_metadata(&path) { + Ok(meta) if meta.file_type().is_socket() => fs::remove_file(&path)?, + Ok(_) => return Err(Error::NotASocket(path)), + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + let listener = tokio::net::UnixListener::bind(&path)?; + Ok(Self { + listener, + path, + protocols: Vec::new(), + }) + } + + /// Advertise these application protocols (moq ALPNs) for in-band negotiation, + /// in preference order. The first server entry the client also offers wins. + pub fn with_protocols(mut self, protocols: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.protocols = protocols.into_iter().map(Into::into).collect(); + self + } + + /// Set the socket file's permission bits (e.g. `0o660`). + pub fn set_mode(&self, mode: u32) -> Result<()> { + fs::set_permissions(&self.path, fs::Permissions::from_mode(mode))?; + Ok(()) + } + + /// The bound socket path. + pub fn path(&self) -> &Path { + &self.path + } + + /// Accept the next connection, returning the session and the peer's credentials. + /// + /// Returns `None` only if the listener itself is gone; a per-connection + /// failure is yielded as `Some(Err(..))` so the accept loop keeps running. + pub async fn accept(&self) -> Option> { + match self.listener.accept().await { + Ok((stream, _addr)) => { + let cred = match stream.peer_cred() { + Ok(cred) => PeerCred { + uid: cred.uid(), + gid: cred.gid(), + pid: cred.pid(), + }, + Err(err) => return Some(Err(err.into())), + }; + let session = qmux::uds::Config::new(WIRE_VERSION) + .protocols(self.protocols.iter().map(String::as_str)) + .accept(stream) + .await + .map_err(Error::Accept); + Some(session.map(|session| (session, cred))) + } + Err(err) => Some(Err(err.into())), + } + } +} + +impl Drop for Listener { + fn drop(&mut self) { + // Best-effort: don't leave a stale socket file behind. + let _ = fs::remove_file(&self.path); + } +} diff --git a/rs/moq-net/src/model/frame.rs b/rs/moq-net/src/model/frame.rs index 1d6923de1..3d83f5f0d 100644 --- a/rs/moq-net/src/model/frame.rs +++ b/rs/moq-net/src/model/frame.rs @@ -13,10 +13,14 @@ use crate::{Error, Result, Timestamp}; /// untrusted peer could otherwise request a multi-gigabyte allocation with a /// single varint. Subscribers reject frames whose declared size exceeds this. /// +/// Matches the per-group cache cap (`MAX_GROUP_CACHE`), so a single frame may fill +/// a group. 16 MiB was too tight for a high-bitrate CMAF fragment carried as one +/// frame; 32 MiB covers that while keeping the per-frame preallocation bounded. +/// // TODO enforce this in [Frame::produce] / [FrameProducer::new] so the limit is // guaranteed for every caller, not just the wire decode paths. Blocked on // making the constructor fallible (returning [Result]), which is an API break. -pub(crate) const MAX_FRAME_SIZE: u64 = 16 * 1024 * 1024; +pub(crate) const MAX_FRAME_SIZE: u64 = 32 * 1024 * 1024; /// A chunk of data with an upfront size and optional presentation timestamp. /// diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index a365d7a2e..470bca67d 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -17,6 +17,8 @@ use crate::{Error, Result, Timescale}; use super::{Frame, FrameConsumer, FrameProducer}; /// Maximum total size of frames cached in a group before old frames are evicted. +/// +/// Kept equal to `MAX_FRAME_SIZE` so a single maximum-size frame can fill a group's cache. const MAX_GROUP_CACHE: u64 = 32 * 1024 * 1024; // 32 MB /// Maximum number of frames cached in a group before old frames are evicted. diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 6d3c2785a..f1d98e7a4 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -21,13 +21,16 @@ path = "src/main.rs" doc = false [features] -default = ["iroh", "quinn", "websocket"] +default = ["iroh", "quinn", "websocket", "uds"] iroh = ["moq-native/iroh"] jemalloc = ["moq-native/jemalloc"] noq = ["moq-native/noq"] quinn = ["moq-native/quinn"] quiche = ["moq-native/quiche"] websocket = ["moq-native/websocket", "dep:qmux", "axum/ws"] +# Unix-domain-socket internal listener (`unix://`), unix-only. In the default +# set; drop via --no-default-features on platforms without Unix sockets. +uds = ["moq-native/uds"] [dependencies] anyhow = { version = "1", features = ["backtrace"] } @@ -39,7 +42,7 @@ futures = "0.3" http-body = "1" http-cache-reqwest = { version = "0.16", features = ["manager-moka"], default-features = false } jsonwebtoken = "10" -moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs", "watch"] } +moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs", "watch", "tcp"] } moq-net = { workspace = true, features = ["serde"] } moq-token = { workspace = true, features = ["tokio"] } qmux = { workspace = true, features = ["ws"], optional = true } @@ -57,6 +60,7 @@ tower-http = { version = "0.6", features = ["cors"] } tower-service = "0.3" tracing = "0.1" url = { version = "2", features = ["serde"] } +web-transport-trait = { workspace = true } [target.'cfg(unix)'.dependencies] sd-notify = "0.5" @@ -64,5 +68,4 @@ sd-notify = "0.5" [dev-dependencies] rcgen = "0.14" tempfile = "3" -web-transport-trait = { workspace = true } wiremock = "0.6" diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index dbc3d6de0..087f5c8ba 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -1,7 +1,7 @@ use clap::Parser; use serde::{Deserialize, Serialize}; -use crate::{AuthConfig, ClusterConfig, StatsConfig, WebConfig}; +use crate::{AuthConfig, ClusterConfig, InternalConfig, StatsConfig, WebConfig}; /// Top-level relay configuration, loadable from CLI arguments, environment /// variables, or a TOML file. @@ -40,6 +40,11 @@ pub struct Config { #[serde(default)] pub web: WebConfig, + /// Optionally run an unauthenticated plain-TCP listener for trusted clients. + #[command(flatten)] + #[serde(default)] + pub internal: InternalConfig, + /// Stats publishing configuration. Disabled unless `stats.enabled = true`. #[command(flatten)] #[serde(default)] @@ -323,6 +328,54 @@ id = 12345 ); } + /// Same clap+TOML clobber guard for the internal listeners. Both + /// `internal.tcp.listen` (`Option`) and `internal.uds.listen` + /// (`Option`) must survive the `update_from` re-parse when their + /// CLI flags are absent, or a TOML-configured listener gets silently + /// disabled. + static INTERNAL_LISTEN_ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn cli_does_not_clobber_toml_internal_listen() { + let _guard = INTERNAL_LISTEN_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // SAFETY: INTERNAL_LISTEN_ENV_LOCK serializes this with any sibling test + // touching the same env vars. + unsafe { + std::env::remove_var("MOQ_INTERNAL_LISTEN"); + std::env::remove_var("MOQ_INTERNAL_UDS_LISTEN"); + } + + let toml = r#" +[internal.tcp] +listen = "127.0.0.1:4444" + +[internal.uds] +listen = "/run/moq/internal.sock" + +[internal.uds.allow] +uid = [1001] +"#; + let dir = std::env::temp_dir().join("moq-relay-config-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("internal-listen-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.internal.tcp.listen, + Some("127.0.0.1:4444".parse().unwrap()), + "TOML's internal.tcp.listen must not be clobbered by the CLI re-parse" + ); + assert_eq!( + config.internal.uds.listen.as_deref(), + Some(std::path::Path::new("/run/moq/internal.sock")), + "TOML's internal.uds.listen must not be clobbered by the CLI re-parse" + ); + assert_eq!(config.internal.uds.allow.uid, vec![1001]); + } + #[test] fn cli_flag_overrides_toml_cluster_id() { let _guard = CLUSTER_ID_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs new file mode 100644 index 000000000..d7c1ed6c4 --- /dev/null +++ b/rs/moq-relay/src/internal.rs @@ -0,0 +1,245 @@ +use std::net::SocketAddr; +use std::path::PathBuf; + +use clap::Parser; +use serde::{Deserialize, Serialize}; +use tracing::Instrument; + +use crate::{AuthToken, Cluster}; + +/// Configuration for the unauthenticated internal listener(s). +/// +/// A TCP and a Unix-socket listener can each be enabled independently. Both +/// grant every accepted connection full internal access (publish and subscribe +/// to everything, no JWT or client certificate), so only expose them to trusted +/// clients. This is the local-worker analogue of a cluster peer dialing `/`. +#[derive(Parser, Clone, Debug, Deserialize, Serialize, Default)] +#[serde(deny_unknown_fields, default)] +#[non_exhaustive] +pub struct InternalConfig { + /// Plain-TCP listener (`tcp://`). + #[command(flatten)] + #[serde(default)] + pub tcp: InternalTcp, + + /// Unix-socket listener (`unix://`), with an optional peer-credential allowlist. + #[command(flatten)] + #[serde(default)] + pub uds: InternalUds, +} + +/// Plain-TCP internal listener. +/// +/// TCP carries no peer identity, so it must only be reachable from trusted +/// clients. Bind it to loopback or a private interface; a non-loopback bind +/// logs a warning but is allowed. +#[derive(Parser, Clone, Debug, Deserialize, Serialize, Default)] +#[serde(deny_unknown_fields, default)] +#[non_exhaustive] +pub struct InternalTcp { + /// Bind an unauthenticated plain-TCP (qmux, no TLS) listener on this address. + #[arg(long = "internal-listen", id = "internal-listen", env = "MOQ_INTERNAL_LISTEN")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, +} + +/// Unix-socket internal listener. +/// +/// The kernel reports the connecting process's credentials, so [`allow`](Self::allow) +/// can restrict callers to a specific worker user. Requires the `uds` build feature. +#[derive(Parser, Clone, Debug, Deserialize, Serialize, Default)] +#[serde(deny_unknown_fields, default)] +#[non_exhaustive] +pub struct InternalUds { + /// Bind an unauthenticated Unix-socket (qmux, no TLS) listener at this path. + #[arg( + long = "internal-uds-listen", + id = "internal-uds-listen", + env = "MOQ_INTERNAL_UDS_LISTEN" + )] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, + + /// Peer-credential allowlist applied to accepted connections. + #[command(flatten)] + #[serde(default)] + pub allow: InternalAllow, +} + +/// Peer-credential allowlist for the Unix-socket internal listener. +/// +/// Each populated field constrains the corresponding credential; an empty field +/// imposes no constraint. A connection is allowed when it satisfies every +/// populated field (AND across fields, OR within a field). All empty means no +/// check, so the socket's filesystem permissions are the only gate. +#[derive(Parser, Clone, Debug, Deserialize, Serialize, Default)] +#[serde(deny_unknown_fields, default)] +#[non_exhaustive] +pub struct InternalAllow { + /// Allowed peer user IDs. Empty means any uid. + #[arg(long = "internal-allow-uid", env = "MOQ_INTERNAL_ALLOW_UID", value_delimiter = ',')] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub uid: Vec, + + /// Allowed peer group IDs. Empty means any gid. + #[arg(long = "internal-allow-gid", env = "MOQ_INTERNAL_ALLOW_GID", value_delimiter = ',')] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gid: Vec, + + /// Allowed peer process IDs. Empty means any pid. A populated list rejects + /// peers whose pid the platform doesn't report. + #[arg(long = "internal-allow-pid", env = "MOQ_INTERNAL_ALLOW_PID", value_delimiter = ',')] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pid: Vec, +} + +impl InternalAllow { + /// Whether this allowlist imposes any constraint. + #[cfg_attr(not(all(feature = "uds", unix)), allow(dead_code))] + fn is_empty(&self) -> bool { + self.uid.is_empty() && self.gid.is_empty() && self.pid.is_empty() + } +} + +/// Run the configured internal listener(s) until one fails; wait forever if none. +/// +/// Used directly in the relay's top-level `select!`. The TCP and Unix listeners +/// run concurrently when both are configured. +pub async fn run_internal(config: InternalConfig, cluster: Cluster) -> anyhow::Result<()> { + let tcp = { + let cluster = cluster.clone(); + async move { + match config.tcp.listen { + Some(addr) => run_tcp(addr, cluster).await, + None => std::future::pending().await, + } + } + }; + + let uds = async move { + match config.uds.listen { + Some(path) => run_uds(path, config.uds.allow, cluster).await, + None => std::future::pending().await, + } + }; + + tokio::select! { + res = tcp => res, + res = uds => res, + } +} + +async fn run_tcp(addr: SocketAddr, cluster: Cluster) -> anyhow::Result<()> { + // No transport security, so a non-loopback bind is worth flagging. We still + // allow it (private VPC interfaces are a valid use), just loudly. + if addr.ip().is_loopback() { + tracing::info!(%addr, "internal listener (tcp)"); + } else { + tracing::warn!(%addr, "internal listener bound to a non-loopback address; it is UNAUTHENTICATED, ensure the network is trusted"); + } + + let listener = moq_native::tcp::Listener::bind(addr) + .await? + .with_protocols(moq_net::ALPNS.iter().copied()); + while let Some(session) = listener.accept().await { + match session { + Ok(session) => spawn_session(session, cluster.clone()), + Err(err) => tracing::warn!(%err, "internal listener accept failed"), + } + } + + anyhow::bail!("internal TCP listener stopped accepting connections") +} + +#[cfg(all(feature = "uds", unix))] +async fn run_uds(path: PathBuf, allow: InternalAllow, cluster: Cluster) -> anyhow::Result<()> { + if allow.is_empty() { + tracing::warn!(path = %path.display(), "internal Unix listener has no allow list; any local user able to reach the socket gets full access"); + } else { + tracing::info!(path = %path.display(), ?allow, "internal listener (unix)"); + } + + let listener = moq_native::unix::Listener::bind(&path) + .await? + .with_protocols(moq_net::ALPNS.iter().copied()); + // Loose file permissions: the uid/gid/pid allow list is the real gate, and + // the worker typically runs as a different user than the relay. + listener.set_mode(0o666)?; + + while let Some(accepted) = listener.accept().await { + let (session, cred) = match accepted { + Ok(accepted) => accepted, + Err(err) => { + tracing::warn!(%err, "internal listener accept failed"); + continue; + } + }; + + if !cred_allowed(&allow, &cred) { + tracing::warn!(uid = cred.uid, gid = cred.gid, pid = ?cred.pid, "internal connection rejected by allow list"); + drop(session); + continue; + } + + spawn_session(session, cluster.clone()); + } + + anyhow::bail!("internal Unix listener stopped accepting connections") +} + +#[cfg(not(all(feature = "uds", unix)))] +async fn run_uds(path: PathBuf, _allow: InternalAllow, _cluster: Cluster) -> anyhow::Result<()> { + anyhow::bail!( + "internal.uds.listen requests a Unix socket ({}) but this relay was built without the `uds` feature", + path.display() + ) +} + +#[cfg(all(feature = "uds", unix))] +fn cred_allowed(allow: &InternalAllow, cred: &moq_native::unix::PeerCred) -> bool { + let uid_ok = allow.uid.is_empty() || allow.uid.contains(&cred.uid); + let gid_ok = allow.gid.is_empty() || allow.gid.contains(&cred.gid); + // A required pid can't be satisfied if the platform doesn't report one. + let pid_ok = allow.pid.is_empty() || cred.pid.is_some_and(|pid| allow.pid.contains(&pid)); + uid_ok && gid_ok && pid_ok +} + +/// Spawn a task that serves one accepted session with full internal access. +fn spawn_session(session: S, cluster: Cluster) +where + S: web_transport_trait::Session, +{ + // Full access to everything under the empty root, on the internal tier. + let token = AuthToken::unrestricted(moq_net::Path::new("").to_owned()); + let publish = cluster.publisher(&token); + let subscribe = cluster.subscriber(&token); + let stats = cluster.stats.tier(moq_net::Tier::Internal); + + let serve = async move { + // subscribe/publish look backwards on purpose: see connection.rs. We publish + // the tracks the client may subscribe to, and subscribe to what it may publish. + // Only set the side the token grants; moq-net defaults the unset side to a + // fresh no-op origin. + let mut server = moq_net::Server::new().with_stats(stats); + if let Some(subscribe) = subscribe { + server = server.with_publisher(&subscribe); + } + if let Some(publish) = publish { + server = server.with_subscriber(publish); + } + let session = server.accept(session).await?; + + tracing::info!(version = %session.version(), "negotiated"); + session.closed().await?; + anyhow::Ok(()) + }; + + tokio::spawn( + async move { + if let Err(err) = serve.await { + tracing::warn!(%err, "internal connection closed"); + } + } + .instrument(tracing::info_span!("internal")), + ); +} diff --git a/rs/moq-relay/src/lib.rs b/rs/moq-relay/src/lib.rs index e3139ca34..6dc4a649e 100644 --- a/rs/moq-relay/src/lib.rs +++ b/rs/moq-relay/src/lib.rs @@ -12,6 +12,7 @@ mod cluster; mod config; mod connection; mod http_client; +mod internal; mod stats; mod web; #[cfg(feature = "websocket")] @@ -25,5 +26,6 @@ pub use auth::*; pub use cluster::*; pub use config::*; pub use connection::*; +pub use internal::*; pub use stats::*; pub use web::*; diff --git a/rs/moq-relay/src/main.rs b/rs/moq-relay/src/main.rs index 681713309..8b9d27b36 100644 --- a/rs/moq-relay/src/main.rs +++ b/rs/moq-relay/src/main.rs @@ -80,6 +80,7 @@ async fn main() -> anyhow::Result<()> { tokio::select! { Err(err) = cluster.clone().run() => return Err(err).context("cluster failed"), Err(err) = web.run() => return Err(err).context("web server failed"), + Err(err) = run_internal(config.internal, cluster.clone()) => return Err(err).context("internal server failed"), Err(err) = serve(server, cluster, auth) => return Err(err).context("server failed"), Err(err) = jemalloc => return Err(err).context("jemalloc profiler failed"), else => Ok(()), diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 8cb941f2c..bcdc0612d 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -10,7 +10,9 @@ use std::{net::TcpListener, sync::atomic::AtomicU64, time::Duration}; use moq_native::moq_net::{self, Origin}; -use moq_relay::{AuthConfig, Cluster, ClusterConfig, PublicConfig, Web, WebConfig, WebState}; +use moq_relay::{ + AuthConfig, Cluster, ClusterConfig, InternalConfig, PublicConfig, Web, WebConfig, WebState, run_internal, +}; const TIMEOUT: Duration = Duration::from_secs(10); @@ -257,6 +259,223 @@ async fn two_publish_only_clients_coexist() { web_handle.abort(); } +/// Stand up just the unauthenticated internal listener (plain-TCP qmux, no +/// auth stack) on a free loopback port. Returns the port and an abort handle. +async fn spawn_internal_relay() -> (u16, tokio::task::JoinHandle<()>) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let cluster = Cluster::new(ClusterConfig::default()).expect("cluster init"); + + // Pick a free TCP port, then drop the probe so the listener can bind it. + 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 internal = InternalConfig::default(); + internal.tcp.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + + let handle = tokio::spawn(async move { + // `run_internal` only returns on error; aborted at teardown. + let _ = run_internal(internal, cluster).await; + }); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { + break; + } + if std::time::Instant::now() >= deadline { + panic!("internal listener never became ready on port {port}"); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + + (port, handle) +} + +/// Connect a publisher and subscriber to the unauthenticated internal listener +/// over `tcp://` (plain TCP, no TLS, no JWT) and confirm a frame round-trips. +/// Exercises the qmux-over-TCP transport and the unrestricted internal grant. +#[tokio::test] +async fn internal_tcp_round_trip() { + let (port, handle) = spawn_internal_relay().await; + // The raw-TCP transport dials host:port only; any URL path is ignored. + let url: url::Url = format!("tcp://127.0.0.1:{port}").parse().expect("parse url"); + let expected_version = newest_lite_version(); + + // ── publisher ─────────────────────────────────────────────────── + let pub_origin = Origin::random().produce(); + let mut broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + let mut track = broadcast.create_track("video", None).expect("create track"); + let mut group = track.append_group().expect("append group"); + group.write_frame(b"hello".as_ref()).expect("write frame"); + group.finish().expect("finish group"); + + let pub_session = tokio::time::timeout( + TIMEOUT, + client().with_publisher(pub_origin.consume()).connect(url.clone()), + ) + .await + .expect("publisher connect timeout") + .expect("publisher connect failed"); + assert_eq!( + pub_session.version(), + expected_version, + "publisher should negotiate the newest moq-lite version in-band over TCP" + ); + + // ── subscriber ────────────────────────────────────────────────── + let sub_origin = Origin::random().produce(); + let mut announcements = sub_origin.consume().announced(); + let sub_session = tokio::time::timeout(TIMEOUT, client().with_subscriber(sub_origin).connect(url)) + .await + .expect("subscriber connect timeout") + .expect("subscriber connect failed"); + + // ── data path ─────────────────────────────────────────────────── + // The internal listener grants the empty root, so the broadcast announces + // at its own name with no path prefix. + let (path, bc) = tokio::time::timeout(TIMEOUT, announcements.next()) + .await + .expect("announcement timeout") + .expect("origin closed"); + assert_eq!(path.as_str(), "test"); + let bc = bc.broadcast().expect("expected announce, got unannounce"); + + let mut track_sub = bc + .track("video") + .unwrap() + .subscribe(None) + .unwrap() + .await + .expect("consume_track"); + let mut group_sub = tokio::time::timeout(TIMEOUT, track_sub.recv_group()) + .await + .expect("recv_group timeout") + .expect("recv_group failed") + .expect("track closed prematurely"); + let frame = tokio::time::timeout(TIMEOUT, group_sub.read_frame()) + .await + .expect("read_frame timeout") + .expect("read_frame failed") + .expect("group closed prematurely"); + assert_eq!(&*frame, b"hello"); + + drop(track); + drop(broadcast); + drop(pub_session); + drop(sub_session); + handle.abort(); +} + +/// Stand up the internal listener on a Unix socket and return the socket path +/// plus an abort handle. +#[cfg(unix)] +async fn spawn_internal_unix_relay() -> (std::path::PathBuf, tokio::task::JoinHandle<()>) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let cluster = Cluster::new(ClusterConfig::default()).expect("cluster init"); + + // Keep the path short: macOS caps AF_UNIX paths around 104 bytes, and the + // system temp dir is long. /tmp is fine on macOS and Linux. + let path = std::path::PathBuf::from(format!("/tmp/moq-internal-{}.sock", std::process::id())); + + let mut internal = InternalConfig::default(); + internal.uds.listen = Some(path.clone()); + + let handle = tokio::spawn(async move { + let _ = run_internal(internal, cluster).await; + }); + + // Wait for the socket file to appear. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if tokio::net::UnixStream::connect(&path).await.is_ok() { + break; + } + if std::time::Instant::now() >= deadline { + panic!("internal Unix listener never became ready at {}", path.display()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + + (path, handle) +} + +/// Connect over `unix://` (qmux on a Unix socket) and confirm a frame +/// round-trips. Also asserts both sides land on the newest moq-lite version, +/// which proves the in-band ALPN negotiation populated the protocol. +#[cfg(unix)] +#[tokio::test] +async fn internal_unix_round_trip() { + let (path, handle) = spawn_internal_unix_relay().await; + // `unix://` + an absolute path yields the triple-slash form the client expects. + let url: url::Url = format!("unix://{}", path.display()).parse().expect("parse url"); + let expected_version = newest_lite_version(); + + // ── publisher ─────────────────────────────────────────────────── + let pub_origin = Origin::random().produce(); + let mut broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + let mut track = broadcast.create_track("video", None).expect("create track"); + let mut group = track.append_group().expect("append group"); + group.write_frame(b"hello".as_ref()).expect("write frame"); + group.finish().expect("finish group"); + + let pub_session = tokio::time::timeout( + TIMEOUT, + client().with_publisher(pub_origin.consume()).connect(url.clone()), + ) + .await + .expect("publisher connect timeout") + .expect("publisher connect failed"); + assert_eq!( + pub_session.version(), + expected_version, + "publisher should negotiate the newest moq-lite version in-band over the Unix socket" + ); + + // ── subscriber ────────────────────────────────────────────────── + let sub_origin = Origin::random().produce(); + let mut announcements = sub_origin.consume().announced(); + let sub_session = tokio::time::timeout(TIMEOUT, client().with_subscriber(sub_origin).connect(url)) + .await + .expect("subscriber connect timeout") + .expect("subscriber connect failed"); + + // ── data path ─────────────────────────────────────────────────── + let (announced_path, bc) = tokio::time::timeout(TIMEOUT, announcements.next()) + .await + .expect("announcement timeout") + .expect("origin closed"); + assert_eq!(announced_path.as_str(), "test"); + let bc = bc.broadcast().expect("expected announce, got unannounce"); + + let mut track_sub = bc + .track("video") + .unwrap() + .subscribe(None) + .unwrap() + .await + .expect("consume_track"); + let mut group_sub = tokio::time::timeout(TIMEOUT, track_sub.recv_group()) + .await + .expect("recv_group timeout") + .expect("recv_group failed") + .expect("track closed prematurely"); + let frame = tokio::time::timeout(TIMEOUT, group_sub.read_frame()) + .await + .expect("read_frame timeout") + .expect("read_frame failed") + .expect("group closed prematurely"); + assert_eq!(&*frame, b"hello"); + + drop(track); + drop(broadcast); + drop(pub_session); + drop(sub_session); + handle.abort(); +} + /// `/health` is a liveness probe that always returns `200 ok`. #[tokio::test] async fn health_endpoint_reports_ok() {