diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index 47d84cd1e7..0000000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# Custom labels for the moq-dev self-hosted A1 runner, so actionlint accepts -# `runs-on: [self-hosted, nix]`. `self-hosted` is built in; `nix` is ours. -self-hosted-runner: - labels: - - nix diff --git a/.github/homebrew/Formula/moq-cli.rb.tmpl b/.github/homebrew/Formula/moq-cli.rb.tmpl index 2f92dcf94f..ac9e3ad7f7 100644 --- a/.github/homebrew/Formula/moq-cli.rb.tmpl +++ b/.github/homebrew/Formula/moq-cli.rb.tmpl @@ -27,10 +27,10 @@ class MoqCli < Formula end def install - bin.install "bin/moq-cli" + bin.install "bin/moq" end test do - system bin/"moq-cli", "--help" + system bin/"moq", "--help" end end diff --git a/.github/workflows/apt-repo.yml b/.github/workflows/apt-repo.yml index 582e68bbf0..3395fed74f 100644 --- a/.github/workflows/apt-repo.yml +++ b/.github/workflows/apt-repo.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index d9296752d5..6092d02e3f 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -28,13 +28,11 @@ jobs: runs-on: ubuntu-latest - os: macos-latest # aarch64-darwin runs-on: macos-latest - # aarch64-linux on the moq-dev self-hosted A1 (warm /nix/store). Tag - # pushes are trusted, so no fork concern. - os: aarch64-linux - runs-on: [self-hosted, nix] + runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -53,17 +51,7 @@ jobs: fi echo "name=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" - # The cachix action and `nix build` below run `nix` directly, so put it on - # PATH. A login shell resolves it via /etc/profile.d regardless of the - # box's install path. Self-hosted only; the hosted runners get Nix from - # the installer step. - - name: Add Nix to PATH - if: runner.environment == 'self-hosted' - shell: bash -leo pipefail {0} - run: dirname "$(command -v nix)" >> "$GITHUB_PATH" - - - if: runner.environment == 'github-hosted' - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: determinate: false @@ -71,11 +59,6 @@ jobs: with: name: kixelated authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - # The self-hosted box's runner user isn't a Nix trusted-user and the - # box deliberately substitutes only from its warm local store, so - # don't let `cachix use` rewrite nix.conf there. Push auth (above) - # still works; this leg only pushes. - skipAddingSubstituter: ${{ runner.environment == 'self-hosted' }} - name: Build and cache env: diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9483d6fb92..f2c386f597 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -22,77 +22,34 @@ jobs: # 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. - runs-on: ${{ github.event.pull_request.head.repo.fork && fromJSON('["ubuntu-24.04-arm"]') || fromJSON('["self-hosted", "nix"]') }} + runs-on: ubuntu-24.04-arm # Backstop a hang (e.g. a wedged test) instead of holding the runner for the # 6-hour default. Generous enough for a cold first build. timeout-minutes: 60 steps: - # GitHub-hosted fallback only: the self-hosted box already has disk space, - # Determinate Nix, and a warm store. - name: Free disk space - if: runner.environment == 'github-hosted' uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main with: tool-cache: false - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false # Full history so `just changed` can diff against origin/$GITHUB_BASE_REF. fetch-depth: 0 - - if: runner.environment == 'github-hosted' - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: determinate: false - - if: runner.environment == 'github-hosted' - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main - - if: runner.environment == 'github-hosted' - uses: DeterminateSystems/flake-checker-action@a0f068d37b542f3150564fbf1164fec2d958ee11 # main + - uses: DeterminateSystems/flake-checker-action@a0f068d37b542f3150564fbf1164fec2d958ee11 # main - # GitHub-hosted fallback caches Rust via the Actions cache. The self-hosted - # box runs the heavy Rust CI (clippy/doc/test) as crane derivations via - # `nix flake check`, so deps cache in /nix/store and there's no persistent - # CARGO_TARGET_DIR to manage -- the residual `cargo check`s use the - # workspace ./target, which the runner wipes per job. + # Cache Rust dependencies and build artifacts via the Actions cache. - name: Rust Cache - if: runner.environment == 'github-hosted' uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: cache-on-failure: true - # Mark a baseline so the post-run assertion can tell whether anything wrote - # under the old persistent target dir (i.e. a CARGO_TARGET_DIR override - # leaked back in and would re-grow an unbounded tree). - - name: Target-dir baseline - if: runner.environment == 'self-hosted' - run: touch "$RUNNER_TEMP/target-baseline" - # `just ci` calls `just changed` internally to skip unchanged scopes. - # Login shell so /etc/profile.d puts Nix on PATH; Actions shells don't by - # default. Harmless on the hosted fallback. - run: nix develop --command just ci - shell: bash -leo pipefail {0} - - # Invariant: nothing should write to the old persistent target dir anymore. - # Fails loudly if a CARGO_TARGET_DIR override sneaks back in. Scope it to - # *this* repo's old per-runner dir (moq-$RUNNER_NAME) -- the box's - # $HOME/cargo-target is shared with other repos (e.g. moq-pro) and runners, - # whose jobs may run concurrently and legitimately still write there. - - name: Assert the persistent target dir stayed unused - if: runner.environment == 'self-hosted' - run: | - target="$HOME/cargo-target/moq-$RUNNER_NAME" - mkdir -p "$target" - leaked=$(find "$target" -type f -newer "$RUNNER_TEMP/target-baseline" 2>/dev/null | head) - if [ -n "$leaked" ]; then - echo "::error::cargo wrote to $target; a CARGO_TARGET_DIR override leaked back in" - echo "$leaked" - exit 1 - fi - echo "OK: $target untouched this run" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a911d57eb2..3e8f1ac82b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -45,7 +45,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/libmoq.yml b/.github/workflows/libmoq.yml index f59c4404f4..38daf67510 100644 --- a/.github/workflows/libmoq.yml +++ b/.github/workflows/libmoq.yml @@ -38,7 +38,7 @@ jobs: use_nix: false steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -82,7 +82,7 @@ jobs: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -108,3 +108,105 @@ jobs: RELEASE_TITLE: "libmoq v${{ steps.parse.outputs.version }}" RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} run: .github/scripts/release.sh create artifacts + + # The OBS plugin (cpp/obs) statically links libmoq, so it ships with every + # libmoq release: rebuild it against the release we just published (no + # second cargo build, guaranteed-matching ABI) and cut a parallel + # obs-moq-v release. Runs after `release` so the libmoq archives + # the plugin's CMake fetches are already live. + obs-build: + name: OBS plugin (${{ matrix.target }}) + needs: release + runs-on: ${{ matrix.os }} + + # macOS + Windows only. These link the ffmpeg the obs-deps bundle ships + # (the same one OBS uses), so the binaries are portable. A Linux build + # would link nix/distro ffmpeg and not be portable, so Linux is + # build-from-source (see doc/bin/obs.md) and ships no prebuilt binary. + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + + steps: + # Shallow: this job only compiles, it never reads git history or tags. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Parse version + id: parse + shell: bash + run: .github/scripts/release.sh parse-version libmoq + + # Native Xcode / Visual Studio; libobs/Qt6/ffmpeg come from the obs-deps + # bundle and libmoq from the release we just published (--libmoq-release). + - name: Build and package + shell: bash + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ steps.parse.outputs.version }} + run: | + ./cpp/obs/build.sh --target "$TARGET" --libmoq-release "$VERSION" --output dist + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: obs-moq-${{ matrix.target }} + path: dist/* + + obs-release: + name: OBS plugin release + needs: obs-build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Parse version + id: parse + run: .github/scripts/release.sh parse-version libmoq + + # Only the obs-moq archives: the libmoq-* artifacts from the build job + # belong to the libmoq release, not this one. + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: artifacts + pattern: obs-moq-* + merge-multiple: true + + # release.sh prev-tag keys off the current tag, but the trigger here is a + # libmoq-v* tag, so it can't find the previous obs-moq tag. Take the + # newest existing obs-moq tag (excluding this version, for safe re-runs) + # so the release notes span only since the last obs-moq release. + - name: Find previous obs-moq tag + id: prev_tag + env: + VERSION: ${{ steps.parse.outputs.version }} + run: | + cur="obs-moq-v${VERSION}" + # awk (not grep -vx | head) so an empty result is exit 0, not 1: + # under `set -eo pipefail` the inaugural release has no prior tag and + # grep's exit 1 would fail the step. Empty tag is fine downstream. + prev=$(git tag --list 'obs-moq-v*' --sort=-v:refname | awk -v cur="$cur" '$0 != cur { print; exit }') + echo "tag=${prev}" >>"$GITHUB_OUTPUT" + + # Cut a parallel obs-moq-v release at this commit (gh creates + # the tag). + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: "obs-moq-v${{ steps.parse.outputs.version }}" + RELEASE_TITLE: "obs-moq v${{ steps.parse.outputs.version }}" + RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} + run: .github/scripts/release.sh create artifacts diff --git a/.github/workflows/moq-cli.yml b/.github/workflows/moq-cli.yml index 0bdc07cd74..e98d27b19e 100644 --- a/.github/workflows/moq-cli.yml +++ b/.github/workflows/moq-cli.yml @@ -31,7 +31,7 @@ jobs: rpm-arch: aarch64 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -54,21 +54,10 @@ jobs: # and AlmaLinux/RHEL/Rocky 9+ (.rpm). run: cargo zigbuild --release --target ${{ matrix.target }}.2.34 -p moq-cli - - name: Rename binary - shell: bash - env: - TARGET: ${{ matrix.target }} - run: | - # The crate is named moq-cli but the .deb / .rpm and Homebrew - # formula present the binary as `moq`. Cargo produces - # target/release/moq-cli by default (no [[bin]] override), - # so make a sibling copy that the downstream packaging steps - # can point at without each one needing to know the rename. - cp "target/${TARGET}/release/moq-cli" "target/${TARGET}/release/moq" - - name: Package bare binary shell: bash run: | + # The crate is named moq-cli but its `[[bin]]` ships as `moq`. version="${{ steps.parse.outputs.version }}" target="${{ matrix.target }}" name="moq-cli-v${version}-${target}" @@ -83,7 +72,7 @@ jobs: run: | name="moq-cli-${VERSION}-${TARGET}" mkdir -p "dist/${name}/bin" - cp "target/${TARGET}/release/moq-cli" "dist/${name}/bin/moq-cli" + cp "target/${TARGET}/release/moq" "dist/${name}/bin/moq" cp LICENSE-MIT LICENSE-APACHE "dist/${name}/" if [[ -f rs/moq-cli/README.md ]]; then cp rs/moq-cli/README.md "dist/${name}/" @@ -140,7 +129,7 @@ jobs: os: macos-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -166,6 +155,7 @@ jobs: run: | ./rs/scripts/package-binary.sh \ --crate moq-cli \ + --bin moq \ --target ${{ matrix.target }} \ --version ${{ steps.parse.outputs.version }} \ --output dist @@ -187,7 +177,7 @@ jobs: - target: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -213,6 +203,7 @@ jobs: run: | ./rs/scripts/package-windows.sh \ --crate moq-cli \ + --bin moq \ --target ${{ matrix.target }} \ --version ${{ steps.parse.outputs.version }} \ --output dist @@ -232,7 +223,7 @@ jobs: actions: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/moq-gst.yml b/.github/workflows/moq-gst.yml index d8c6f3ec5d..cf1819318c 100644 --- a/.github/workflows/moq-gst.yml +++ b/.github/workflows/moq-gst.yml @@ -32,7 +32,7 @@ jobs: os: macos-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -116,7 +116,7 @@ jobs: rpm-arch: aarch64 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -203,7 +203,7 @@ jobs: actions: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/moq-relay.yml b/.github/workflows/moq-relay.yml index 261c2e31ea..515f46c131 100644 --- a/.github/workflows/moq-relay.yml +++ b/.github/workflows/moq-relay.yml @@ -34,7 +34,7 @@ jobs: rpm-arch: aarch64 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -129,7 +129,7 @@ jobs: os: macos-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -176,7 +176,7 @@ jobs: - target: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -221,7 +221,7 @@ jobs: actions: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/moq-token-cli.yml b/.github/workflows/moq-token-cli.yml index 92b7acd456..0f9a3ab4c7 100644 --- a/.github/workflows/moq-token-cli.yml +++ b/.github/workflows/moq-token-cli.yml @@ -31,7 +31,7 @@ jobs: rpm-arch: aarch64 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -126,7 +126,7 @@ jobs: os: macos-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -173,7 +173,7 @@ jobs: - target: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -218,7 +218,7 @@ jobs: actions: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/release-brew.yml b/.github/workflows/release-brew.yml index 85a3fed373..d77218fa03 100644 --- a/.github/workflows/release-brew.yml +++ b/.github/workflows/release-brew.yml @@ -38,7 +38,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -120,7 +120,7 @@ jobs: permission-contents: write - name: Checkout homebrew-tap - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: moq-dev/homebrew-tap token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/release-go.yml b/.github/workflows/release-go.yml index ae9d363e5a..ab5d306c40 100644 --- a/.github/workflows/release-go.yml +++ b/.github/workflows/release-go.yml @@ -39,7 +39,7 @@ jobs: if: ${{ github.repository_owner == 'moq-dev' }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -108,7 +108,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/release-js.yml b/.github/workflows/release-js.yml index 4f235d71fb..704e97c3a9 100644 --- a/.github/workflows/release-js.yml +++ b/.github/workflows/release-js.yml @@ -18,26 +18,25 @@ concurrency: jobs: release: name: Release JS Packages - # Trusted push to main, so run on the moq-dev self-hosted A1 runner (warm - # /nix/store). - runs-on: [self-hosted, nix] + runs-on: ubuntu-latest if: github.repository_owner == 'moq-dev' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + with: + determinate: false + - name: Setup npm registry uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: registry-url: 'https://registry.npmjs.org' - # Login shell so /etc/profile.d puts Nix on PATH; Actions shells don't by - # default. - name: Install dependencies run: nix develop --command bun install --frozen-lockfile - shell: bash -leo pipefail {0} - name: Release packages # Web-component packages register custom elements via global type @@ -46,4 +45,3 @@ jobs: env: JSR_ALLOW_SLOW_TYPES: "true" run: nix develop --command bun --filter '*' release - shell: bash -leo pipefail {0} diff --git a/.github/workflows/release-kt-ffi.yml b/.github/workflows/release-kt-ffi.yml index 3e22c49e88..66da677593 100644 --- a/.github/workflows/release-kt-ffi.yml +++ b/.github/workflows/release-kt-ffi.yml @@ -24,7 +24,7 @@ jobs: version: ${{ steps.parse.outputs.version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -59,7 +59,7 @@ jobs: os: windows-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -105,7 +105,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -143,11 +143,11 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: temurin java-version: "17" diff --git a/.github/workflows/release-py-ffi.yml b/.github/workflows/release-py-ffi.yml index dcc71f1758..cc2c112fa2 100644 --- a/.github/workflows/release-py-ffi.yml +++ b/.github/workflows/release-py-ffi.yml @@ -26,7 +26,7 @@ jobs: version: ${{ steps.parse.outputs.version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -79,7 +79,7 @@ jobs: os: windows-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -110,7 +110,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/release-py.yml b/.github/workflows/release-py.yml index 3175bf1508..14fad7e2d0 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -31,10 +31,7 @@ concurrency: jobs: build: name: Build wheel + sdist - # Trusted push to main and the wrapper wheel is pure-python (arch - # independent), so run on the moq-dev self-hosted A1 runner (warm - # /nix/store). - runs-on: [self-hosted, nix] + runs-on: ubuntu-latest if: ${{ github.repository_owner == 'moq-dev' }} outputs: version: ${{ steps.version.outputs.version }} @@ -42,7 +39,7 @@ jobs: publish: ${{ steps.pypi.outputs.exists == 'false' }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -59,11 +56,12 @@ jobs: VERSION: ${{ steps.version.outputs.version }} run: .github/scripts/release.sh pypi-exists moq-rs "$VERSION" - # Login shell so /etc/profile.d puts Nix on PATH; Actions shells don't by - # default. + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + with: + determinate: false + - name: Build run: nix develop --command just py package - shell: bash -leo pipefail {0} - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/release-rs.yml b/.github/workflows/release-rs.yml index 9f477a0100..049649b34e 100644 --- a/.github/workflows/release-rs.yml +++ b/.github/workflows/release-rs.yml @@ -18,9 +18,7 @@ jobs: release: name: Plz - # Trusted push to main, so run on the moq-dev self-hosted A1 runner (warm - # /nix/store + persistent CARGO_TARGET_DIR). - runs-on: [self-hosted, nix] + runs-on: ubuntu-latest if: ${{ github.repository_owner == 'moq-dev' }} steps: @@ -34,21 +32,18 @@ jobs: private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 token: ${{ steps.generate-token.outputs.token }} - # Persist the cargo target outside the workspace (checkout clean wipes the - # workspace, not $HOME). Scope per runner so a second service on the box - # gets its own dir instead of racing on a shared one. - - run: echo "CARGO_TARGET_DIR=$HOME/cargo-target/moq-$RUNNER_NAME" >> "$GITHUB_ENV" + # Install Nix for system dependencies (e.g. ffmpeg). + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + with: + determinate: false - # Login shell so /etc/profile.d puts Nix on PATH; Actions shells don't by - # default. - name: Release run: nix develop --command just rs release - shell: bash -leo pipefail {0} env: GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/release-swift-ffi.yml b/.github/workflows/release-swift-ffi.yml index dd5936f11a..f7f0ba33b9 100644 --- a/.github/workflows/release-swift-ffi.yml +++ b/.github/workflows/release-swift-ffi.yml @@ -26,7 +26,7 @@ jobs: version: ${{ steps.parse.outputs.version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -52,7 +52,7 @@ jobs: - universal-apple-darwin steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -89,7 +89,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -120,7 +120,7 @@ jobs: runs-on: macos-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -173,7 +173,7 @@ jobs: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # No git operations here use the stored credential. gh CLI below picks @@ -208,7 +208,7 @@ jobs: runs-on: macos-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -230,7 +230,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/rpm-repo.yml b/.github/workflows/rpm-repo.yml index 5951537d70..4f0a33f3a2 100644 --- a/.github/workflows/rpm-repo.yml +++ b/.github/workflows/rpm-repo.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index aea570becc..b04c90520c 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -43,14 +43,13 @@ jobs: tool-cache: false - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: determinate: false - - uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # main # Reuse ~/.cargo + ./target across nightly runs so only changed crates # recompile (the relay, cli, moq-ffi, and libmoq all build from source). diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml index cd96544c16..8afccd3fbc 100644 --- a/.github/workflows/update-flake.yml +++ b/.github/workflows/update-flake.yml @@ -12,21 +12,16 @@ on: jobs: update-flake: name: Update flake.lock - # Run on the moq-dev self-hosted A1 runner (already has Nix); fork dispatches - # have no such runner, so guard on the owner to avoid queueing forever. - runs-on: [self-hosted, nix] - if: ${{ github.repository_owner == 'moq-dev' }} + runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - # The update-flake-lock action runs `nix` directly (not through a shell), - # so put Nix on GITHUB_PATH. A login shell resolves it via /etc/profile.d - # regardless of the box's install path. - - name: Add Nix to PATH - shell: bash -leo pipefail {0} - run: dirname "$(command -v nix)" >> "$GITHUB_PATH" + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + with: + determinate: false - name: Update flake.lock uses: DeterminateSystems/update-flake-lock@fd9359ac79d0e912f1b4b947a48470b3e2799b56 # main diff --git a/CLAUDE.md b/CLAUDE.md index fd8b87a3ea..37aa79bd03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ Top-level layout only. Per-crate and per-package detail lives in the nested guid - `/rs/` - Rust crates: core networking (`moq-net`), native helpers, the relay, CLIs, media muxing/codecs, and the FFI/C bindings. See `rs/CLAUDE.md`. - `/js/` - TypeScript/JavaScript packages for the browser, published as `@moq/*`. See `js/CLAUDE.md`. - `/py/`, `/swift/`, `/kt/`, `/go/` - language wrappers over `rs/moq-ffi` (see [Language Bindings](#language-bindings)). `/py/` has `py/CLAUDE.md`; the others defer to their `README.md`. +- `/cpp/` - C/C++ consumers of `libmoq`. `cpp/obs/` is the OBS Studio plugin (CMake; links `libmoq` via `MOQ_LOCAL`), licensed GPL-2.0-or-later because it links `libobs`. See `doc/bin/obs.md`. - `/demo/` - demos and test media: relay configs, the web demo, MoQ Boy, media hosting, and a network throttle script. - `/doc/` - documentation site (VitePress, deployed via Cloudflare). @@ -161,6 +162,7 @@ Changes in one area usually need matching updates elsewhere, including docs. If | `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` | +| `rs/libmoq` C ABI (`moq.h`) | `cpp/obs/src`, `doc/bin/obs.md` | | `js/{watch,publish}` UI/API | `demo/web` if it consumes the API | **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. diff --git a/Cargo.lock b/Cargo.lock index a90d86b0d3..ae092e5d5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,7 +83,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -232,7 +232,7 @@ dependencies = [ "memchr", "proc-macro2", "quote", - "rustc-hash", + "rustc-hash 2.1.2", "serde", "serde_derive", "syn 2.0.117", @@ -565,12 +565,46 @@ dependencies = [ ] [[package]] -name = "bincode" -version = "1.3.3" +name = "bindgen" +version = "0.65.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" dependencies = [ - "serde", + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", + "which", +] + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.117", ] [[package]] @@ -579,14 +613,14 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools 0.13.0", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.2", "shlex", "syn 2.0.117", ] @@ -600,6 +634,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -662,7 +702,7 @@ version = "4.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0e3bc837369e9e662d3845c374ac3771b054d4bc2dee5457591198d16d684c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "boring-sys", "foreign-types", "libc", @@ -675,12 +715,51 @@ version = "4.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15aad385759d3da2737772aefb391332227bef7cb71fb85c106cadd9d1e63a98" dependencies = [ - "bindgen", + "bindgen 0.72.1", "cmake", "fs_extra", "fslock", ] +[[package]] +name = "boytacean" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333afc1e8f6a0bbba0a916346a2fca124c153b6ec42f5c8d795c05126503ee0b" +dependencies = [ + "boytacean-common", + "boytacean-encoding", + "boytacean-hashing", + "built", + "chrono", + "regex", +] + +[[package]] +name = "boytacean-common" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe79b9ef2249700e02747de11cbc048870767705b8c31615a820bda6d94f81f" + +[[package]] +name = "boytacean-encoding" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7edc2850784c0e2b0a55e7d4e7823ef60c4493be021c13f6010fca19d75edfa" +dependencies = [ + "boytacean-common", + "boytacean-hashing", +] + +[[package]] +name = "boytacean-hashing" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221540d0cb3bc7222b46c061e330ce05622ed739ed2f7247864ab7ec5f63dfa3" +dependencies = [ + "boytacean-common", +] + [[package]] name = "bs58" version = "0.5.1" @@ -690,12 +769,27 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" +dependencies = [ + "cargo-lock", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -729,6 +823,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cargo-lock" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06acb4f71407ba205a07cb453211e0e6a67b21904e47f6ba1f9589e38f2e454" +dependencies = [ + "semver", + "serde", + "toml 0.8.23", + "url", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -758,6 +864,25 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "clap", + "heck", + "indexmap 2.14.0", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.117", + "tempfile", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "cc" version = "1.2.62" @@ -917,7 +1042,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -1123,7 +1248,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -1352,6 +1477,15 @@ dependencies = [ "cmov", ] +[[package]] +name = "cudarc" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42310153e06cf4cd532901f7096beb27504d681736a29ee90728ae4e2d93b2a8" +dependencies = [ + "libloading 0.9.0", +] + [[package]] name = "curve25519-dalek" version = "5.0.0-rc.0" @@ -1726,7 +1860,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -2436,7 +2570,7 @@ version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" dependencies = [ - "bitflags", + "bitflags 2.11.1", "futures-channel", "futures-core", "futures-executor", @@ -2555,7 +2689,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94668bc2592732b8c2b653668ae41211d45988fb61264888b9c2d545d4bd826d" dependencies = [ "chrono", - "toml_edit", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -2633,7 +2767,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.19.2" +version = "0.19.3" dependencies = [ "anyhow", "bytes", @@ -2819,6 +2953,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hostname" version = "0.4.2" @@ -2865,42 +3008,49 @@ dependencies = [ [[package]] name = "http-cache" -version = "0.21.0" +version = "1.0.0-alpha.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1437561a949a2168929d7559aecd2f0ecb18b9e676080396388cdc399ddb723a" +checksum = "d01e0b5d3afe17eadd68dad863adc0715b7ccfa887effbb9ea4de3c7c78c6c44" dependencies = [ - "async-trait", - "bincode", + "bytes", + "futures", + "hex", "http", + "http-body", "http-cache-semantics", "httpdate", + "log", "moka", + "pin-project-lite", + "postcard", "serde", "url", ] [[package]] name = "http-cache-reqwest" -version = "0.16.0" +version = "1.0.0-alpha.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccca775a066b2a65da33611921e078ebdcfc27065c8bee96794703541101886" +checksum = "3ff1fa00fd5b0d38c26d5f24f1cf513e286a988b57880c2bf357304669268fa3" dependencies = [ "anyhow", "async-trait", + "bytes", "http", + "http-body", + "http-body-util", "http-cache", "http-cache-semantics", - "reqwest 0.12.28", + "reqwest", "reqwest-middleware", - "serde", "url", ] [[package]] name = "http-cache-semantics" -version = "2.1.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4311240f94cb6fe622337dc580b09ddd6ae4a891eb121dba20cf4f77ca4e7129" +checksum = "0603b99309a1e404c2c0945650c0f775b50bb72ac90ae570cb93f9ff05d9efe7" dependencies = [ "http", "http-serde", @@ -2908,12 +3058,6 @@ dependencies = [ "time", ] -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - [[package]] name = "http-serde" version = "2.1.1" @@ -2996,7 +3140,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -3229,7 +3372,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -3327,8 +3470,8 @@ dependencies = [ "portable-atomic", "portmapper", "rand 0.10.1", - "reqwest 0.13.3", - "rustc-hash", + "reqwest", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "serde", @@ -3442,7 +3585,7 @@ dependencies = [ "pin-project", "postcard", "rand 0.10.1", - "reqwest 0.13.3", + "reqwest", "rustls", "rustls-pki-types", "serde", @@ -3660,7 +3803,7 @@ dependencies = [ [[package]] name = "kio" -version = "0.4.0" +version = "0.4.1" dependencies = [ "smallvec", ] @@ -3681,7 +3824,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", ] @@ -3694,6 +3837,12 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3716,12 +3865,42 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmoq" +version = "0.3.9" +dependencies = [ + "anyhow", + "bytes", + "cbindgen", + "hang", + "moq-audio", + "moq-mux", + "moq-native", + "moq-net", + "moq-video", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -3858,16 +4037,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3918,7 +4087,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.0.5" +version = "0.0.6" dependencies = [ "block2", "bytes", @@ -3955,9 +4124,57 @@ dependencies = [ "url", ] +[[package]] +name = "moq-boy" +version = "0.2.22" +dependencies = [ + "anyhow", + "boytacean", + "bytes", + "clap", + "hang", + "moq-audio", + "moq-json", + "moq-mux", + "moq-native", + "moq-net", + "moq-video", + "serde", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "moq-cli" +version = "0.8.0" +dependencies = [ + "anyhow", + "axum", + "axum-server", + "bytes", + "clap", + "hang", + "humantime", + "moq-audio", + "moq-hls", + "moq-mux", + "moq-native", + "moq-rtc", + "moq-rtmp", + "moq-srt", + "moq-video", + "rustls", + "sd-notify", + "tokio", + "tower-http 0.7.0", + "tracing", + "url", +] + [[package]] name = "moq-ffi" -version = "0.2.24" +version = "0.2.25" dependencies = [ "bytes", "hang", @@ -3985,7 +4202,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.2.8" +version = "0.2.9" dependencies = [ "anyhow", "bytes", @@ -4007,29 +4224,22 @@ version = "0.0.1" dependencies = [ "anyhow", "axum", - "axum-server", "bytes", - "clap", "hang", - "humantime", "kio", "m3u8-rs", "moq-mux", - "moq-native", "moq-net", - "reqwest 0.12.28", - "rustls", - "sd-notify", + "reqwest", "thiserror 2.0.18", "tokio", - "tower-http 0.7.0", "tracing", "url", ] [[package]] name = "moq-json" -version = "0.1.0" +version = "0.1.1" dependencies = [ "bytes", "criterion", @@ -4053,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-msf" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -4062,7 +4272,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "base64", @@ -4093,7 +4303,7 @@ dependencies = [ [[package]] name = "moq-native" -version = "0.17.4" +version = "0.17.5" dependencies = [ "anyhow", "bytes", @@ -4103,6 +4313,7 @@ dependencies = [ "hex", "humantime", "humantime-serde", + "jni 0.21.1", "moq-net", "notify", "parking_lot", @@ -4110,9 +4321,10 @@ dependencies = [ "quinn", "rand 0.10.1", "rcgen", - "reqwest 0.12.28", + "reqwest", "rustls", "rustls-native-certs", + "rustls-platform-verifier 0.6.2", "rustls-webpki", "serde", "serde_with", @@ -4135,12 +4347,13 @@ dependencies = [ "web-transport-quiche", "web-transport-quinn", "web-transport-trait", + "webpki-roots", "x509-parser", ] [[package]] name = "moq-net" -version = "0.1.12" +version = "0.1.13" dependencies = [ "bytes", "futures", @@ -4158,7 +4371,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.12.13" +version = "0.13.1" dependencies = [ "anyhow", "axum", @@ -4174,7 +4387,7 @@ dependencies = [ "moq-token", "qmux", "rcgen", - "reqwest 0.12.28", + "reqwest", "reqwest-middleware", "rustls", "sd-notify", @@ -4200,20 +4413,14 @@ version = "0.0.1" dependencies = [ "anyhow", "axum", - "axum-server", "bytes", - "clap", "hang", "moq-mux", - "moq-native", "moq-net", - "reqwest 0.12.28", - "rustls", - "sd-notify", + "reqwest", "str0m", "thiserror 2.0.18", "tokio", - "tower-http 0.7.0", "tracing", "url", "uuid", @@ -4224,24 +4431,18 @@ name = "moq-rtmp" version = "0.0.1" dependencies = [ "anyhow", - "axum", - "axum-server", "bytes", - "clap", "futures", "moq-mux", "moq-native", "moq-net", "rml_rtmp", "rustls", - "sd-notify", "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tokio-rustls", - "tower-http 0.7.0", "tracing", - "url", ] [[package]] @@ -4249,23 +4450,14 @@ name = "moq-srt" version = "0.0.1" dependencies = [ "anyhow", - "axum", - "axum-server", "bytes", - "clap", "futures", - "humantime", "moq-mux", - "moq-native", "moq-net", - "rustls", - "sd-notify", "srt-tokio", "thiserror 2.0.18", "tokio", - "tower-http 0.7.0", "tracing", - "url", ] [[package]] @@ -4279,7 +4471,7 @@ dependencies = [ "jsonwebtoken", "p256", "p384", - "reqwest 0.12.28", + "reqwest", "rsa", "serde", "serde_json", @@ -4290,13 +4482,60 @@ dependencies = [ [[package]] name = "moq-token-cli" -version = "0.5.31" +version = "0.5.32" dependencies = [ "anyhow", "clap", "moq-token", ] +[[package]] +name = "moq-vaapi" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c32870bce5b00614c94229081d57f0bc93877321ec1868ad21fcb547c54e2a59" +dependencies = [ + "anyhow", + "bindgen 0.70.1", + "bitflags 2.11.1", + "log", + "pkg-config", + "regex", + "thiserror 1.0.69", +] + +[[package]] +name = "moq-video" +version = "0.0.6" +dependencies = [ + "anyhow", + "block2", + "bytes", + "cudarc", + "dispatch2", + "hang", + "libloading 0.8.9", + "moq-mux", + "moq-net", + "moq-vaapi", + "objc2", + "objc2-av-foundation", + "objc2-core-foundation", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation", + "objc2-screen-capture-kit", + "objc2-video-toolbox", + "openh264", + "thiserror 2.0.18", + "tokio", + "tracing", + "v4l", + "windows", + "yuv", + "zune-jpeg", +] + [[package]] name = "moq-wasm" version = "0.0.0" @@ -4397,13 +4636,22 @@ dependencies = [ "n0-future", ] +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "log", +] + [[package]] name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags", + "bitflags 2.11.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -4432,7 +4680,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags", + "bitflags 2.11.1", "byteorder", "derive_builder", "getset", @@ -4497,7 +4745,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "log", "netlink-packet-core", @@ -4509,7 +4757,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "log", "netlink-packet-core", @@ -4585,7 +4833,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -4629,7 +4877,7 @@ dependencies = [ "noq-proto", "noq-udp", "pin-project-lite", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2 0.6.4", "thiserror 2.0.18", @@ -4657,7 +4905,7 @@ dependencies = [ "rand 0.10.1", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "rustls-platform-verifier 0.7.0", @@ -4688,7 +4936,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags", + "bitflags 2.11.1", "fsevent-sys", "inotify", "kqueue", @@ -4706,7 +4954,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -4890,7 +5138,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "objc2", "objc2-core-audio", @@ -4905,7 +5153,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2", "dispatch2", "objc2", @@ -4927,7 +5175,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2", "objc2-foundation", ] @@ -4951,7 +5199,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2", ] @@ -4961,7 +5209,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2", "dispatch2", "libc", @@ -4974,7 +5222,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.11.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -4997,7 +5245,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ - "bitflags", + "bitflags 2.11.1", + "block2", "dispatch2", "objc2", "objc2-core-audio", @@ -5012,11 +5261,13 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags", + "bitflags 2.11.1", + "block2", "objc2", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", + "objc2-metal", ] [[package]] @@ -5025,7 +5276,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -5045,7 +5296,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -5069,7 +5320,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", ] @@ -5086,15 +5337,45 @@ dependencies = [ "objc2-core-media", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags", + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-screen-capture-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch2", + "libc", "objc2", + "objc2-av-foundation", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", "objc2-foundation", + "objc2-uniform-type-identifiers", ] [[package]] @@ -5103,7 +5384,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", ] @@ -5124,7 +5405,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "bitflags", + "bitflags 2.11.1", "dispatch2", "libc", "objc2", @@ -5132,6 +5413,33 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-video-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05bf9a3c14831a7d9641b0d81d87dd913ee238a012b2fde27db5a84b56f5df3e" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation", + "objc2-metal", +] + [[package]] name = "object" version = "0.37.3" @@ -5184,6 +5492,27 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openh264" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a12b82c14f702c2cece4e0fc28896c6a6bed5317dc13448c86ac41df91a6f82" +dependencies = [ + "openh264-sys2", + "wide", +] + +[[package]] +name = "openh264-sys2" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa9e072e9b270f3b291c80488dc160abc31ecc214ab3bfde937213cfd8c83b32" +dependencies = [ + "cc", + "nasm-rs", + "walkdir", +] + [[package]] name = "openssl-macros" version = "0.1.1" @@ -5352,6 +5681,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem" version = "3.0.6" @@ -5662,7 +5997,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -5702,7 +6037,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hex", "procfs-core", "rustix 0.38.44", @@ -5714,7 +6049,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hex", ] @@ -5776,7 +6111,7 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bitflags", + "bitflags 2.11.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -5902,7 +6237,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2 0.6.4", "thiserror 2.0.18", @@ -5924,7 +6259,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "rustls-platform-verifier 0.6.2", @@ -6070,7 +6405,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -6114,7 +6449,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -6166,44 +6501,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http 0.6.11", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - [[package]] name = "reqwest" version = "0.13.3" @@ -6224,6 +6521,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier 0.7.0", @@ -6243,16 +6541,15 @@ dependencies = [ [[package]] name = "reqwest-middleware" -version = "0.4.2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", "http", - "reqwest 0.12.28", - "serde", - "thiserror 1.0.69", + "reqwest", + "thiserror 2.0.18", "tower-service", ] @@ -6351,6 +6648,12 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -6381,7 +6684,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6394,7 +6697,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6511,6 +6814,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6595,7 +6907,7 @@ dependencies = [ "crc", "log", "rand 0.9.4", - "rustc-hash", + "rustc-hash 2.1.2", "slab", "thiserror 2.0.18", ] @@ -6640,7 +6952,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b04b276c2f79846b7968abe6f87cedf951e06fd2a2b72d99c457e85d7e40f3fb" dependencies = [ - "bitflags", + "bitflags 2.11.1", "byteorder", "bytes", "nutype-enum", @@ -6684,7 +6996,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6792,6 +7104,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -7010,7 +7331,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -7211,7 +7532,7 @@ dependencies = [ "aes", "array-init", "arraydeque", - "bitflags", + "bitflags 2.11.1", "bytes", "cipher", "ctr", @@ -7260,9 +7581,9 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "str0m" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "431befc786d98bfa860118d96890df038e4db51635494203bbe600b05582f920" +checksum = "ca05746700d3621a27d7b99beaf2e724f8940608cbab00c3e4ebd974620668af" dependencies = [ "arrayvec", "base64ct", @@ -7395,7 +7716,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -7787,6 +8108,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -7795,7 +8128,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -7810,13 +8143,22 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned", + "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", "winnow 1.0.3", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -7835,6 +8177,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.11+spec-1.1.0" @@ -7856,6 +8212,12 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -7925,7 +8287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -7947,21 +8309,11 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", - "futures-core", - "futures-util", "http", - "http-body", - "http-body-util", - "http-range-header", - "httpdate", - "mime", - "mime_guess", "percent-encoding", "pin-project-lite", - "tokio", - "tokio-util", "tower-layer", "tower-service", ] @@ -8144,12 +8496,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -8370,6 +8716,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v4l" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403" +dependencies = [ + "bitflags 1.3.2", + "libc", + "v4l2-sys-mit", +] + +[[package]] +name = "v4l2-sys-mit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" +dependencies = [ + "bindgen 0.65.1", +] + [[package]] name = "valuable" version = "0.1.1" @@ -8575,7 +8941,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -8792,6 +9158,28 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "widestring" version = "1.2.1" @@ -9295,7 +9683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", @@ -9464,6 +9852,15 @@ dependencies = [ "synstructure", ] +[[package]] +name = "yuv" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d85a782d94ee43f078bcfd6fa82d4e6a5b2d1cfbbad168e4df5a9f7b39ef48c" +dependencies = [ + "num-traits", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -9591,3 +9988,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 8cfa2faa1f..064cb093a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,22 +62,25 @@ rust-version = "1.85" flate2 = "1.1" hang = { version = "0.19", path = "rs/hang" } kio = { version = "0.4", path = "rs/kio" } -moq-audio = { version = "0.0.5", path = "rs/moq-audio" } +moq-audio = { version = "0.0.6", path = "rs/moq-audio" } moq-flate = { version = "0.1.0", path = "rs/moq-flate" } moq-hls = { version = "0.0.1", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.1.0", path = "rs/moq-json" } +moq-json = { version = "0.1.1", path = "rs/moq-json" } moq-loc = { version = "0.1", path = "rs/moq-loc" } -moq-msf = { version = "0.2", path = "rs/moq-msf" } -moq-mux = { version = "0.6", path = "rs/moq-mux" } +moq-msf = { version = "0.3", path = "rs/moq-msf" } +moq-mux = { version = "0.7", path = "rs/moq-mux" } moq-native = { version = "0.17", path = "rs/moq-native", default-features = false } moq-net = { version = "0.1", path = "rs/moq-net" } +moq-rtc = { version = "0.0.1", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.0.1", path = "rs/moq-rtmp" } +moq-srt = { version = "0.0.1", path = "rs/moq-srt" } 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" # default-features off (the `nvenc` / `vaapi` hardware encoders) so each consumer # opts in: binaries (libmoq, moq-boy, moq-cli) enable them, but a self-compiler can # leave them off to drop the CUDA / libva deps. moq-video itself still defaults them on. -moq-video = { version = "0.0.5", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.0.6", path = "rs/moq-video", default-features = false } qmux = { version = "0.2", default-features = false } serde = { version = "1", features = ["derive"] } tokio = "1.48" diff --git a/Dockerfile b/Dockerfile index 230958d100..c77df07b7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,9 +28,13 @@ RUN --mount=type=cache,target=/root/.cache --mount=type=cache,target=/nix,from=n # Default to `/bin/sh` for the entrypoint if no package is specified ARG package="sh" -# Create entry.sh script that knows which binary to run -RUN echo '#!/bin/sh' > /output/entry.sh && \ - echo "exec /bin/${package} \"\$@\"" >> /output/entry.sh && \ +# Create entry.sh script that knows which binary to run. Derive it from the +# single binary the package produced (so a crate whose `[[bin]]` name differs +# from its package name, e.g. `moq-cli` shipping as `moq`, just works); fall +# back to the package name when there's no binary (the `sh` default). +RUN binary="$(ls /output/result/bin 2>/dev/null | head -n1)"; \ + [ -n "$binary" ] || binary="${package}"; \ + printf '#!/bin/sh\nexec /bin/%s "$@"\n' "${binary}" > /output/entry.sh && \ chmod +x /output/entry.sh # Final image (when no specific package is selected, defaults to sh) diff --git a/README.md b/README.md index ed87bee238..5e8b0fac3b 100644 --- a/README.md +++ b/README.md @@ -135,3 +135,5 @@ Licensed under either: - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0) - MIT license ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT) + +**Exception:** the OBS plugin under [`cpp/obs/`](cpp/obs) is licensed under **GPL-2.0-or-later** (see [`cpp/obs/LICENSE`](cpp/obs/LICENSE)), because it links OBS Studio's `libobs`, which is GPL-2.0. This is a separately-distributable work; per GPLv2 its presence in this repository is mere aggregation and does not affect the MIT/Apache licensing of the rest of the project. `libmoq` and the other moq crates remain MIT/Apache. diff --git a/bun.lock b/bun.lock index 9ca0ea30cd..91ef1ca9c1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "moq", @@ -93,7 +94,7 @@ }, "js/hang": { "name": "@moq/hang", - "version": "0.2.11", + "version": "0.2.12", "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.8.8", @@ -115,7 +116,7 @@ }, "js/json": { "name": "@moq/json", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@moq/flate": "workspace:^", "@moq/net": "workspace:^", @@ -144,7 +145,7 @@ }, "js/moq-boy": { "name": "@moq/boy", - "version": "0.2.10", + "version": "0.2.11", "dependencies": { "@moq/json": "workspace:^", "@moq/net": "workspace:^", @@ -164,7 +165,7 @@ }, "js/msf": { "name": "@moq/msf", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@moq/net": "workspace:^", "zod": "^4.4.3", @@ -176,9 +177,9 @@ }, "js/net": { "name": "@moq/net", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { - "@moq/qmux": "^0.1.1", + "@moq/qmux": "^0.1.3", "@moq/signals": "workspace:*", "async-mutex": "^0.5.0", }, @@ -196,7 +197,7 @@ }, "js/publish": { "name": "@moq/publish", - "version": "0.2.15", + "version": "0.2.16", "dependencies": { "@moq/hang": "workspace:^", "@moq/json": "workspace:^", @@ -215,7 +216,7 @@ }, "js/signals": { "name": "@moq/signals", - "version": "0.1.9", + "version": "0.1.10", "devDependencies": { "@types/bun": "^1.3.11", "@types/react": "^19.2.17", @@ -260,7 +261,7 @@ }, "js/watch": { "name": "@moq/watch", - "version": "0.2.17", + "version": "0.2.18", "dependencies": { "@moq/hang": "workspace:^", "@moq/json": "workspace:^", @@ -582,7 +583,7 @@ "@moq/publish": ["@moq/publish@workspace:js/publish"], - "@moq/qmux": ["@moq/qmux@0.1.1", "", {}, "sha512-ljrQzBiKk3/yQ1+DCmI/F24CYzfePDSt/SUVWNjdtCsOn1CO/9EXHz8CLMlaA1ZFBviAJhWxc4B0CZw6x0E1Wg=="], + "@moq/qmux": ["@moq/qmux@0.1.3", "", { "dependencies": { "@moq/web-socket-stream": "^0.1.0" } }, "sha512-naGmMMOxCSMeLyEATrLiY9RGyeZENC4dHFVVNWNcA17LXE9NwM3aQrQY218xXypWahYIgIkl/ZCutIP2xHibgA=="], "@moq/signals": ["@moq/signals@workspace:js/signals"], @@ -596,6 +597,8 @@ "@moq/watch": ["@moq/watch@workspace:js/watch"], + "@moq/web-socket-stream": ["@moq/web-socket-stream@0.1.0", "", {}, "sha512-VxPaJZvZ8qgFmTjG9XhTH0bQoQE9pExvlS/R0fH1aNcpqCnZdbYS2kvB2ypO5SyFTf6oH+4eWvgElDzW6yiqFQ=="], + "@moq/web-transport": ["@moq/web-transport@0.1.3", "", { "optionalDependencies": { "@moq/web-transport-darwin-arm64": "0.1.3", "@moq/web-transport-darwin-x64": "0.1.3", "@moq/web-transport-linux-arm64-gnu": "0.1.3", "@moq/web-transport-linux-x64-gnu": "0.1.3", "@moq/web-transport-win32-x64-msvc": "0.1.3" } }, "sha512-hg3mKWUJaEwtJDGf8Ck62XP8ya+7wyMV/9ycV1A+M3iVly3xBOTPHpT8BrpOLomsBIB9iXQY6Z85MjknhUJW8w=="], "@moq/web-transport-darwin-arm64": ["@moq/web-transport-darwin-arm64@0.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MSKd2lmgjABS6A4CoaYA2m5XibJFUi3hzz/5TTrWAiRHU8t6WV5iHcPKLMblgXi+QSZ7iLlEL0tXzmN9u0qkJw=="], diff --git a/cpp/obs/.clang-format b/cpp/obs/.clang-format new file mode 100644 index 0000000000..73c5b0faae --- /dev/null +++ b/cpp/obs/.clang-format @@ -0,0 +1,209 @@ +# please use clang-format version 16 or later + +Standard: c++17 +AccessModifierOffset: -8 +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: Left +AlignOperands: true +AlignTrailingComments: true +AllowAllArgumentsOnNextLine: false +AllowAllConstructorInitializersOnNextLine: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: false +AllowShortLambdasOnASingleLine: Inline +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: false +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: true + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Custom +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakStringLiterals: false # apparently unpredictable +ColumnLimit: 120 +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 8 +ContinuationIndentWidth: 8 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +FixNamespaceComments: true +ForEachMacros: + - 'json_object_foreach' + - 'json_object_foreach_safe' + - 'json_array_foreach' + - 'HASH_ITER' +IncludeBlocks: Preserve +IndentCaseLabels: false +IndentPPDirectives: None +IndentWidth: 8 +IndentWrappedFunctionNames: false +KeepEmptyLinesAtTheStartOfBlocks: true +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 8 +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true + +PenaltyBreakAssignment: 10 +PenaltyBreakBeforeFirstCallParameter: 30 +PenaltyBreakComment: 10 +PenaltyBreakFirstLessLess: 0 +PenaltyBreakString: 10 +PenaltyExcessCharacter: 100 +PenaltyReturnTypeOnItsOwnLine: 60 + +PointerAlignment: Right +ReflowComments: false +SkipMacroDefinitionBody: true +SortIncludes: false +SortUsingDeclarations: false +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 1 +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +SpacesInContainerLiterals: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +StatementMacros: + - 'Q_OBJECT' +TabWidth: 8 +TypenameMacros: + - 'DARRAY' +UseTab: ForContinuationAndIndentation +--- +Language: ObjC +AccessModifierOffset: 2 +AlignArrayOfStructures: Right +AlignConsecutiveAssignments: None +AlignConsecutiveBitFields: None +AlignConsecutiveDeclarations: None +AlignConsecutiveMacros: + Enabled: true + AcrossEmptyLines: false + AcrossComments: true +AllowShortBlocksOnASingleLine: Never +AllowShortEnumsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: None +AttributeMacros: ['__unused', '__autoreleasing', '_Nonnull', '__bridge'] +BitFieldColonSpacing: Both +#BreakBeforeBraces: Webkit +BreakBeforeBraces: Custom +BraceWrapping: + AfterCaseLabel: false + AfterClass: true + AfterControlStatement: Never + AfterEnum: false + AfterFunction: true + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + BeforeLambdaBody: false + BeforeWhile: false + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: false + SplitEmptyNamespace: true +BreakAfterAttributes: Never +BreakArrays: false +BreakBeforeConceptDeclarations: Allowed +BreakBeforeInlineASMColon: OnlyMultiline +BreakConstructorInitializers: AfterColon +BreakInheritanceList: AfterComma +ColumnLimit: 120 +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: LogicalBlock +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +IndentAccessModifiers: false +IndentCaseBlocks: false +IndentCaseLabels: true +IndentExternBlock: Indent +IndentGotoLabels: false +IndentRequiresClause: true +IndentWidth: 4 +IndentWrappedFunctionNames: true +InsertBraces: false +InsertNewlineAtEOF: true +KeepEmptyLinesAtTheStartOfBlocks: false +LambdaBodyIndentation: Signature +NamespaceIndentation: All +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 4 +ObjCBreakBeforeNestedBlockParam: false +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true +PPIndentWidth: -1 +PackConstructorInitializers: NextLine +QualifierAlignment: Leave +ReferenceAlignment: Right +RemoveSemicolon: false +RequiresClausePosition: WithPreceding +RequiresExpressionIndentation: OuterScope +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 1 +SortIncludes: false +#SortUsingDeclarations: LexicographicNumeric +SortUsingDeclarations: true +SpaceAfterCStyleCast: true +SpaceAfterLogicalNot: false +SpaceAroundPointerQualifiers: Default +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: true +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInConditionalStatement: false +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: -1 +Standard: c++17 +TabWidth: 4 +UseTab: Never diff --git a/cpp/obs/.gersemirc b/cpp/obs/.gersemirc new file mode 100644 index 0000000000..59c4a78d2a --- /dev/null +++ b/cpp/obs/.gersemirc @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/BlankSpruce/gersemi/master/gersemi/configuration.schema.json + +definitions: [] +line_length: 120 +indent: 2 +list_expansion: favour-inlining +unsafe: false +warn_about_unknown_commands: false diff --git a/cpp/obs/.gitignore b/cpp/obs/.gitignore new file mode 100644 index 0000000000..85d45cad70 --- /dev/null +++ b/cpp/obs/.gitignore @@ -0,0 +1,11 @@ +# CMake build trees (per the platform presets) and CPack output. +build_macos/ +build_x64/ +build_x86_64/ +release/ + +# Downloaded OBS sources + prebuilt obs-deps/Qt6 (macOS/Windows buildspec). +.deps/ + +# Generated build counter. +cmake/.CMakeBuildNumber diff --git a/cpp/obs/CMakeLists.txt b/cpp/obs/CMakeLists.txt new file mode 100644 index 0000000000..138ee7d8cf --- /dev/null +++ b/cpp/obs/CMakeLists.txt @@ -0,0 +1,147 @@ +cmake_minimum_required(VERSION 3.28) + +include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/common/bootstrap.cmake" NO_POLICY_SCOPE) + +project(${_name} VERSION ${_version}) + +option(ENABLE_FRONTEND_API "Use obs-frontend-api for UI functionality" OFF) +option(ENABLE_QT "Use Qt functionality" OFF) + +include(compilerconfig) +include(defaults) +include(helpers) + +add_library(obs-moq MODULE) + +if(${BUILD_PLUGIN}) + find_package(libobs REQUIRED) + # FFmpeg dependency (used by the MoQ source to decode subscribed video). On + # macOS and Windows, link the ffmpeg the obs-deps bundle ships -- the same one + # OBS itself uses at runtime -- via the vendored FindFFmpeg finder (obs-deps + # ship ffmpeg with no pkg-config .pc files, so it searches the prefix). On + # Linux there's no obs-deps; pkg-config finds the system ffmpeg. + if(WIN32 OR APPLE) + find_package(FFmpeg REQUIRED avcodec avutil swscale swresample) + target_link_libraries(obs-moq PRIVATE FFmpeg::avcodec FFmpeg::avutil FFmpeg::swscale FFmpeg::swresample) + else() + include(FindPkgConfig) + pkg_check_modules(FFMPEG REQUIRED libavcodec libavutil libswscale libswresample) + target_include_directories(obs-moq PRIVATE ${FFMPEG_INCLUDE_DIRS}) + target_link_directories(obs-moq PRIVATE ${FFMPEG_LIBRARY_DIRS}) + target_link_libraries(obs-moq PRIVATE ${FFMPEG_LIBRARIES}) + endif() +else() + find_package(FFmpeg REQUIRED avcodec avutil swscale swresample) + target_link_libraries(obs-moq PRIVATE FFmpeg::avcodec FFmpeg::avutil FFmpeg::swscale FFmpeg::swresample) +endif() + +target_link_libraries(obs-moq PRIVATE OBS::libobs) + +# Default to the in-tree moq checkout (this file lives at cpp/obs, so the repo +# root is two levels up). A consumer building cpp/obs in isolation can point +# MOQ_LOCAL elsewhere, or unset it to fall back to the published release. +set(MOQ_LOCAL "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to the moq repo (builds rs/libmoq in-tree)") + +if(MOQ_LOCAL AND EXISTS "${MOQ_LOCAL}/rs/libmoq/CMakeLists.txt") + add_subdirectory("${MOQ_LOCAL}/rs/libmoq" moq) + target_link_libraries(obs-moq PRIVATE moq) +else() + include(FetchContent) + FetchContent_Declare( + moq + URL + https://github.com/moq-dev/moq/releases/download/libmoq-v${MOQ_VERSION}/moq-${MOQ_VERSION}-${MOQ_TARGET}.${MOQ_ARCHIVE} + ) + FetchContent_MakeAvailable(moq) + + find_package(moq REQUIRED PATHS ${moq_SOURCE_DIR} NO_DEFAULT_PATH) + target_link_libraries(obs-moq PRIVATE moq::moq) +endif() + +# libmoq is a Rust static library; its std runtime pulls in symbols from Windows +# system libraries (e.g. NtCreateNamedPipeFile from ntdll) that MSVC does not +# auto-resolve when the archive is linked into this module. These are the OS +# import libraries reported by `rustc --print native-static-libs` that aren't +# linked by default; they resolve by name from the Windows SDK (no hard-coded +# paths). The libmoq package config also declares these, but linking them here +# keeps the plugin buildable against any libmoq (local dev tree or prebuilt +# release) whose config may predate that declaration. Duplicate links are +# harmless. +if(WIN32) + target_link_libraries( + obs-moq + PRIVATE ntdll userenv ws2_32 dbghelp bcrypt + ) +endif() + +# The obs-deps Qt6 build references the AGL framework transitively (via +# WrapOpenGL), but recent macOS SDKs ship no linkable AGL binary -- it exists +# only in the runtime dyld shared cache. Generate a stub whose install name +# points at the real framework so the link succeeds; dyld resolves AGL at load. +if( + (ENABLE_QT OR ENABLE_FRONTEND_API) + AND APPLE + AND NOT EXISTS "${CMAKE_OSX_SYSROOT}/System/Library/Frameworks/AGL.framework/Versions/A/AGL" +) + set(_agl_stub_dir "${CMAKE_BINARY_DIR}/agl-stub") + set(_agl_stub_lib "${_agl_stub_dir}/AGL.framework/AGL") + if(NOT EXISTS "${_agl_stub_lib}") + file(MAKE_DIRECTORY "${_agl_stub_dir}/AGL.framework") + set(_agl_arch_flags "") + foreach(_arch IN LISTS CMAKE_OSX_ARCHITECTURES) + list(APPEND _agl_arch_flags "-arch" "${_arch}") + endforeach() + execute_process( + COMMAND + xcrun clang -dynamiclib ${_agl_arch_flags} -install_name /System/Library/Frameworks/AGL.framework/Versions/A/AGL + -o "${_agl_stub_lib}" -x c /dev/null + RESULT_VARIABLE _agl_stub_result + ) + if(NOT _agl_stub_result EQUAL 0) + message(WARNING "Failed to build AGL stub (${_agl_stub_result}); Qt link may fail") + endif() + endif() + target_link_options(obs-moq PRIVATE "-F${_agl_stub_dir}") +endif() + +if(ENABLE_FRONTEND_API) + find_package(obs-frontend-api REQUIRED) + target_link_libraries(obs-moq PRIVATE OBS::obs-frontend-api) +endif() + +if(ENABLE_QT) + find_package(Qt6 COMPONENTS Widgets Core) + target_link_libraries(obs-moq PRIVATE Qt6::Core Qt6::Widgets) + target_compile_options( + obs-moq + PRIVATE $<$:-Wno-quoted-include-in-framework-header -Wno-comma> + ) + set_target_properties( + obs-moq + PROPERTIES AUTOMOC ON AUTOUIC ON AUTORCC ON + ) +endif() + +target_sources( + obs-moq + PRIVATE + src/obs-moq.cpp + src/moq-output.h + src/moq-service.h + src/moq-output.cpp + src/moq-service.cpp + src/moq-source.cpp + src/moq-source.h +) + +# The dock requires both the frontend API (to register it) and Qt (to build it). +if(ENABLE_FRONTEND_API AND ENABLE_QT) + target_sources(obs-moq PRIVATE src/moq-dock.cpp src/moq-dock.h) + target_compile_definitions(obs-moq PRIVATE MOQ_FRONTEND_ENABLED MOQ_VERSION_STRING="${MOQ_VERSION}") +endif() + +if(${BUILD_PLUGIN}) + set_target_properties_plugin(obs-moq PROPERTIES OUTPUT_NAME ${_name}) +else() + set_target_properties_obs(obs-moq PROPERTIES FOLDER plugins PREFIX "") +endif() diff --git a/cpp/obs/CMakePresets.json b/cpp/obs/CMakePresets.json new file mode 100644 index 0000000000..6ebb63dd59 --- /dev/null +++ b/cpp/obs/CMakePresets.json @@ -0,0 +1,177 @@ +{ + "version": 8, + "cmakeMinimumRequired": { + "major": 3, + "minor": 28, + "patch": 0 + }, + "configurePresets": [ + { + "name": "template", + "hidden": true, + "cacheVariables": { + "ENABLE_FRONTEND_API": true, + "ENABLE_QT": true, + "CMAKE_EXPORT_COMPILE_COMMANDS": true, + "BUILD_PLUGIN": true, + "MOQ_VERSION": "0.3.6", + "MOQ_ARCHIVE": "tar.gz" + } + }, + { + "name": "macos", + "displayName": "macOS Universal", + "description": "Build for macOS 12.0+ (Universal binary)", + "inherits": [ + "template" + ], + "binaryDir": "${sourceDir}/build_macos", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + }, + "generator": "Xcode", + "warnings": { + "dev": true, + "deprecated": true + }, + "cacheVariables": { + "CMAKE_OSX_DEPLOYMENT_TARGET": "12.0", + "CMAKE_OSX_ARCHITECTURES": "arm64", + "CODESIGN_IDENTITY": "$penv{CODESIGN_IDENT}", + "CODESIGN_TEAM": "$penv{CODESIGN_TEAM}", + "MOQ_TARGET": "aarch64-apple-darwin" + } + }, + { + "name": "macos-ci", + "inherits": [ + "macos" + ], + "displayName": "macOS Universal CI build", + "description": "Build for macOS 12.0+ (Universal binary) for CI", + "generator": "Xcode", + "cacheVariables": { + "CMAKE_COMPILE_WARNING_AS_ERROR": true, + "ENABLE_CCACHE": true + } + }, + { + "name": "windows-x64", + "displayName": "Windows x64", + "description": "Build for Windows x64", + "inherits": [ + "template" + ], + "binaryDir": "${sourceDir}/build_x64", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + }, + "generator": "Visual Studio 17 2022", + "architecture": "x64,version=10.0.22621", + "warnings": { + "dev": true, + "deprecated": true + }, + "cacheVariables": { + "MOQ_TARGET": "x86_64-pc-windows-msvc", + "MOQ_ARCHIVE": "zip" + } + }, + { + "name": "windows-ci-x64", + "inherits": [ + "windows-x64" + ], + "displayName": "Windows x64 CI build", + "description": "Build for Windows x64 on CI", + "cacheVariables": { + "CMAKE_COMPILE_WARNING_AS_ERROR": true + } + }, + { + "name": "ubuntu-x86_64", + "displayName": "Ubuntu x86_64", + "description": "Build for Ubuntu x86_64", + "inherits": [ + "template" + ], + "binaryDir": "${sourceDir}/build_x86_64", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + }, + "generator": "Ninja", + "warnings": { + "dev": true, + "deprecated": true + }, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_INSTALL_LIBDIR": "lib/CMAKE_SYSTEM_PROCESSOR-linux-gnu", + "MOQ_TARGET": "x86_64-unknown-linux-gnu" + } + }, + { + "name": "ubuntu-ci-x86_64", + "inherits": [ + "ubuntu-x86_64" + ], + "displayName": "Ubuntu x86_64 CI build", + "description": "Build for Ubuntu x86_64 on CI", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_COMPILE_WARNING_AS_ERROR": true, + "ENABLE_CCACHE": true + } + } + ], + "buildPresets": [ + { + "name": "macos", + "configurePreset": "macos", + "displayName": "macOS Universal", + "description": "macOS build for Universal architectures", + "configuration": "RelWithDebInfo" + }, + { + "name": "macos-ci", + "configurePreset": "macos-ci", + "displayName": "macOS Universal CI", + "description": "macOS CI build for Universal architectures", + "configuration": "RelWithDebInfo" + }, + { + "name": "windows-x64", + "configurePreset": "windows-x64", + "displayName": "Windows x64", + "description": "Windows build for x64", + "configuration": "RelWithDebInfo" + }, + { + "name": "windows-ci-x64", + "configurePreset": "windows-ci-x64", + "displayName": "Windows x64 CI", + "description": "Windows CI build for x64 (RelWithDebInfo configuration)", + "configuration": "RelWithDebInfo" + }, + { + "name": "ubuntu-x86_64", + "configurePreset": "ubuntu-x86_64", + "displayName": "Ubuntu x86_64", + "description": "Ubuntu build for x86_64", + "configuration": "RelWithDebInfo" + }, + { + "name": "ubuntu-ci-x86_64", + "configurePreset": "ubuntu-ci-x86_64", + "displayName": "Ubuntu x86_64 CI", + "description": "Ubuntu CI build for x86_64", + "configuration": "RelWithDebInfo" + } + ] +} diff --git a/cpp/obs/LICENSE b/cpp/obs/LICENSE new file mode 100644 index 0000000000..1b8a5cdd20 --- /dev/null +++ b/cpp/obs/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {description} + Copyright (C) {year} {fullname} + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + {signature of Ty Coon}, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. \ No newline at end of file diff --git a/cpp/obs/README.md b/cpp/obs/README.md new file mode 100644 index 0000000000..2c16160fec --- /dev/null +++ b/cpp/obs/README.md @@ -0,0 +1,22 @@ +# obs-moq + +An OBS Studio plugin for publishing to and subscribing from MoQ relays. + +It loads into a stock OBS Studio install (no OBS source build required) and links +`libmoq`, built from the in-tree [`rs/libmoq`](../../rs/libmoq) crate. + +Build instructions for each platform live in [`doc/bin/obs.md`](../../doc/bin/obs.md). +In short, from the repo root: + +```bash +# Linux: the dev shell provides libobs/Qt6/ffmpeg +nix develop +just obs build + +# macOS / Windows: needs Xcode / Visual Studio 2022; obs-deps download via buildspec.json +just obs setup +just obs build +``` + +Licensed under GPL-2.0-or-later (see [LICENSE](LICENSE)), separate from the rest of the +repository, because it links OBS. diff --git a/cpp/obs/build.sh b/cpp/obs/build.sh new file mode 100755 index 0000000000..4aac4c214b --- /dev/null +++ b/cpp/obs/build.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build and package the obs-moq plugin for release. +# Usage: ./build.sh [--target TARGET] [--version VERSION] [--output DIR] +# +# The required toolchain must already be on PATH; this script only drives +# CMake. Per platform: +# Linux - run inside `nix develop` (provides cmake/ninja/obs-studio/qt6/ffmpeg) +# macOS - full Xcode, run OUTSIDE nix (libobs/Qt6/ffmpeg all come from the +# obs-deps bundle downloaded by buildspec.json at configure time) +# Windows - Visual Studio 2022; run from Git Bash with cmake on PATH +# (libobs/Qt6 downloaded by buildspec.json) +# +# Produces $OUTPUT_DIR/obs-moq-$VERSION-$TARGET.{tar.gz,zip}. The archive is +# unsigned; macOS Gatekeeper / Windows SmartScreen will warn on first load. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +TARGET="" +VERSION="" +OUTPUT_DIR="dist" +MOQ_RELEASE="" + +while [[ $# -gt 0 ]]; do + case $1 in + --target) + TARGET="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + --output) + OUTPUT_DIR="$2" + shift 2 + ;; + --libmoq-release) + # Link a published libmoq release of this version instead of + # building rs/libmoq from source. CMake fetches the matching + # moq-- archive from the GitHub release and the + # plugin is versioned to match. Used by CI on a libmoq-v* tag. + MOQ_RELEASE="$2" + shift 2 + ;; + -h | --help) + echo "Usage: $0 [--target TARGET] [--version VERSION] [--output DIR] [--libmoq-release VERSION]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# In libmoq-release mode the plugin version tracks the libmoq version. +if [[ -n "$MOQ_RELEASE" ]]; then + VERSION="$MOQ_RELEASE" +fi + +if [[ -z "$TARGET" ]]; then + TARGET=$(cc -dumpmachine 2>/dev/null || echo unknown) + echo "Detected target: $TARGET" +fi + +# Default the version from buildspec.json's top-level "version" (the nested +# dependency entries also have "version" keys, hence the leading-indent anchor). +if [[ -z "$VERSION" ]]; then + VERSION=$(grep -E '^[[:space:]]{4}"version"' "$SCRIPT_DIR/buildspec.json" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/') + echo "Detected version: $VERSION" +fi + +# Map the target triple to a CMake preset and build tree. +case "$TARGET" in + *-linux-*) + PRESET="ubuntu-x86_64" + BUILD_DIR="$SCRIPT_DIR/build_x86_64" + KIND="unix" + ;; + *-apple-darwin) + PRESET="macos" + BUILD_DIR="$SCRIPT_DIR/build_macos" + KIND="macos" + ;; + *-windows-*) + PRESET="windows-x64" + BUILD_DIR="$SCRIPT_DIR/build_x64" + KIND="windows" + ;; + *) + echo "Unsupported target: $TARGET" >&2 + exit 1 + ;; +esac + +# Resolve the output dir to an absolute path before we cd into the plugin +# directory (cmake --preset reads CMakePresets.json from the current dir). +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" +cd "$SCRIPT_DIR" + +echo "Building obs-moq $VERSION for $TARGET (preset: $PRESET)..." +CONFIGURE_ARGS=() +# Stamp the plugin's compiled-in version (project version, macOS Info.plist, +# Windows resource) to match what we're building, not buildspec.json's 0.0.1. +if [[ -n "$VERSION" ]]; then + CONFIGURE_ARGS+=("-DPLUGIN_VERSION_OVERRIDE=$VERSION") +fi +if [[ -n "$MOQ_RELEASE" ]]; then + # Empty MOQ_LOCAL forces CMake's release-download branch; MOQ_VERSION and + # MOQ_TARGET steer it at this target's archive (the presets hard-code an + # x86_64/stale default). MOQ_ARCHIVE is correct per preset already. + echo "Linking libmoq release v$MOQ_RELEASE ($TARGET)" + CONFIGURE_ARGS+=(-DMOQ_LOCAL= "-DMOQ_VERSION=$MOQ_RELEASE" "-DMOQ_TARGET=$TARGET") +fi +cmake --preset "$PRESET" ${CONFIGURE_ARGS[@]+"${CONFIGURE_ARGS[@]}"} +cmake --build --preset "$PRESET" + +NAME="obs-moq-${VERSION}-${TARGET}" +STAGE="$OUTPUT_DIR/$NAME" +rm -rf "$STAGE" +mkdir -p "$OUTPUT_DIR" + +if [[ "$KIND" == "macos" ]]; then + # Self-contained loadable bundle; drop into the OBS plugins directory. + PLUGIN=$(find "$BUILD_DIR" -name 'obs-moq.plugin' -maxdepth 4 -print -quit) + [[ -n "$PLUGIN" ]] || { + echo "obs-moq.plugin not found under $BUILD_DIR" >&2 + exit 1 + } + mkdir -p "$STAGE" + cp -R "$PLUGIN" "$STAGE/" +else + # OBS portable-plugin layout: extract into your OBS plugins directory. + LIB=$(find "$BUILD_DIR" \( -name 'obs-moq.so' -o -name 'obs-moq.dll' \) -print -quit) + [[ -n "$LIB" ]] || { + echo "obs-moq.{so,dll} not found under $BUILD_DIR" >&2 + exit 1 + } + mkdir -p "$STAGE/obs-moq/bin/64bit" + cp "$LIB" "$STAGE/obs-moq/bin/64bit/" + cp -R "$SCRIPT_DIR/data" "$STAGE/obs-moq/" +fi + +cp "$SCRIPT_DIR/LICENSE" "$STAGE/" +cp "$SCRIPT_DIR/README.md" "$STAGE/" + +# Archive with CMake's tar so we don't depend on zip/gtar being present +# (notably on the Windows runner). tar.gz on unix, zip on macOS/Windows. +( + cd "$OUTPUT_DIR" + if [[ "$KIND" == "unix" ]]; then + ARCHIVE="$NAME.tar.gz" + cmake -E tar czf "$ARCHIVE" "$NAME" + else + ARCHIVE="$NAME.zip" + cmake -E tar cf "$ARCHIVE" --format=zip "$NAME" + fi + rm -rf "$NAME" + echo "Created: $OUTPUT_DIR/$ARCHIVE" +) diff --git a/cpp/obs/buildspec.json b/cpp/obs/buildspec.json new file mode 100644 index 0000000000..f4e18bad5b --- /dev/null +++ b/cpp/obs/buildspec.json @@ -0,0 +1,45 @@ +{ + "dependencies": { + "obs-studio": { + "version": "31.1.1", + "baseUrl": "https://github.com/obsproject/obs-studio/archive/refs/tags", + "label": "OBS sources", + "hashes": { + "macos": "39751f067bacc13d44b116c5138491b5f1391f91516d3d590d874edd21292291", + "windows-x64": "2c8427c10b55ac6d68008df2e9a3e82f4647aaad18f105e30d4713c2de678ccf" + } + }, + "prebuilt": { + "version": "2025-07-11", + "baseUrl": "https://github.com/obsproject/obs-deps/releases/download", + "label": "Pre-Built obs-deps", + "hashes": { + "macos": "495687e63383d1a287684b6e2e9bfe246bb8f156fe265926afb1a325af1edd2a", + "windows-x64": "c8c642c1070dc31ce9a0f1e4cef5bb992f4bff4882255788b5da12129e85caa7" + } + }, + "qt6": { + "version": "2025-07-11", + "baseUrl": "https://github.com/obsproject/obs-deps/releases/download", + "label": "Pre-Built Qt6", + "hashes": { + "macos": "d3f5f04b6ea486e032530bdf0187cbda9a54e0a49621a4c8ba984c5023998867", + "windows-x64": "0e76bf0555dd5382838850b748d3dcfab44a1e1058441309ab54e1a65b156d0a" + }, + "debugSymbols": { + "windows-x64": "11b7be92cf66a273299b8f3515c07a5cfb61614b59a4e67f7fc5ecba5e2bdf21" + } + } + }, + "platformConfig": { + "macos": { + "bundleId": "dev.montevideotech.obs-moq" + } + }, + "name": "obs-moq", + "displayName": "MOQ for OBS", + "version": "0.0.1", + "author": "Montevideo Tech", + "website": "https://montevideotech.dev", + "email": "emil@qualabs.com" +} \ No newline at end of file diff --git a/cpp/obs/cmake/common/FindFFmpeg.cmake b/cpp/obs/cmake/common/FindFFmpeg.cmake new file mode 100644 index 0000000000..ecba82d397 --- /dev/null +++ b/cpp/obs/cmake/common/FindFFmpeg.cmake @@ -0,0 +1,362 @@ +#[=======================================================================[.rst +FindFFmpeg +---------- + +FindModule for FFmpeg and associated libraries + +.. versionchanged:: 3.0 + Updated FindModule to CMake standards + +Components +^^^^^^^^^^ + +.. versionadded:: 1.0 + +This module contains provides several components: + +``avcodec`` +``avdevice`` +``avfilter`` +``avformat`` +``avutil`` +``postproc`` +``swscale`` +``swresample`` + +Import targets exist for each component. + +Imported Targets +^^^^^^^^^^^^^^^^ + +.. versionadded:: 2.0 + +This module defines the :prop_tgt:`IMPORTED` targets: + +``FFmpeg::avcodec`` + AVcodec component + +``FFmpeg::avdevice`` + AVdevice component + +``FFmpeg::avfilter`` + AVfilter component + +``FFmpeg::avformat`` + AVformat component + +``FFmpeg::avutil`` + AVutil component + +``FFmpeg::postproc`` + postproc component + +``FFmpeg::swscale`` + SWscale component + +``FFmpeg::swresample`` + SWresample component + +Result Variables +^^^^^^^^^^^^^^^^ + +This module sets the following variables: + +``FFmpeg_FOUND`` + True, if all required components and the core library were found. +``FFmpeg_VERSION`` + Detected version of found FFmpeg libraries. +``FFmpeg_INCLUDE_DIRS`` + Include directories needed for FFmpeg. +``FFmpeg_LIBRARIES`` + Libraries needed to link to FFmpeg. +``FFmpeg_DEFINITIONS`` + Compiler flags required for FFmpeg. + +``FFmpeg__VERSION`` + Detected version of found FFmpeg component library. +``FFmpeg__INCLUDE_DIRS`` + Include directories needed for FFmpeg component. +``FFmpeg__LIBRARIES`` + Libraries needed to link to FFmpeg component. +``FFmpeg__DEFINITIONS`` + Compiler flags required for FFmpeg component. + +Cache variables +^^^^^^^^^^^^^^^ + +The following cache variables may also be set: + +``FFmpeg__LIBRARY`` + Path to the library component of FFmpeg. +``FFmpeg__INCLUDE_DIR`` + Directory containing ``.h``. + +#]=======================================================================] + +include(FindPackageHandleStandardArgs) + +set( + _DEFAULT_COMPONENTS + avcodec + avdevice + avformat + avfilter + avresample + avutil + postproc + swscale + swresample +) + +set(component_avcodec libavcodec avcodec avcodec.h) +set(component_avdevice libavdevice avdevice avdevice.h) +set(component_avformat libavformat avformat avformat.h) +set(component_avfilter libavfilter avfilter avfilter.h) +set(component_avresample libavresample avresample avresample.h) +set(component_avutil libavutil avutil avutil.h) +set(component_postproc libpostproc postproc postprocess.h) +set(component_swscale libswscale swscale swscale.h) +set(component_swresample libswresample swresample swresample.h) + +if(NOT FFmpeg_FIND_COMPONENTS) + set(FFmpeg_FIND_COMPONENTS ${_DEFAULT_COMPONENTS}) +endif() + +# FFmpeg_find_component: Find and set up requested FFmpeg component +macro(FFmpeg_find_component component) + list(GET component_${component} 0 component_libname) + list(GET component_${component} 1 component_name) + list(GET component_${component} 2 component_header) + + if(NOT CMAKE_HOST_SYSTEM_NAME MATCHES "Windows") + find_package(PkgConfig QUIET) + if(PKG_CONFIG_FOUND) + pkg_search_module(PC_FFmpeg_${component} QUIET ${component_libname}) + endif() + endif() + + find_path( + FFmpeg_${component}_INCLUDE_DIR + NAMES ${component_libname}/${component_header} ${component_libname}/version.h + HINTS ${PC_FFmpeg_${component}_INCLUDE_DIRS} + PATHS /usr/include /usr/local/include + DOC "FFmpeg component ${component_name} include directory" + ) + + ffmpeg_check_version() + + if(CMAKE_HOST_SYSTEM_NAME MATCHES "Windows") + find_library( + FFmpeg_${component}_IMPLIB + NAMES ${component_libname} ${component_name} + DOC "FFmpeg component ${component_name} import library location" + ) + + ffmpeg_find_dll() + else() + find_library( + FFmpeg_${component}_LIBRARY + NAMES ${component_libname} ${component_name} + HINTS ${PC_FFmpeg_${component}_LIBRARY_DIRS} + PATHS /usr/lib /usr/local/lib + DOC "FFmpeg component ${component_name} location" + ) + endif() + + if(FFmpeg_${component}_LIBRARY AND FFmpeg_${component}_INCLUDE_DIR) + set(FFmpeg_${component}_FOUND TRUE) + set(FFmpeg_${component}_LIBRARIES ${${_library_var}}) + set(FFmpeg_${component}_INCLUDE_DIRS ${FFmpeg_${component}_INCLUDE_DIR}) + set(FFmpeg_${component}_DEFINITIONS ${PC_FFmpeg_${component}_CFLAGS_OTHER}) + mark_as_advanced(FFmpeg_${component}_LIBRARY FFmpeg_${component}_INCLUDE_DIR FFmpeg_${component}_IMPLIB) + endif() +endmacro() + +# FFmpeg_find_dll: Macro to find DLL for corresponding import library +macro(FFmpeg_find_dll) + cmake_path(GET FFmpeg_${component}_IMPLIB PARENT_PATH _implib_path) + cmake_path(SET _bin_path NORMALIZE "${_implib_path}/../bin") + + string(REGEX REPLACE "([0-9]+)\\.[0-9]+\\.[0-9]+" "\\1" _dll_version "${FFmpeg_${component}_VERSION}") + + find_program( + FFmpeg_${component}_LIBRARY + NAMES ${component_name}-${_dll_version}.dll + HINTS ${_implib_path} ${_bin_path} + DOC "FFmpeg component ${component_name} DLL location" + ) + + if(NOT FFmpeg_${component}_LIBRARY) + set(FFmpeg_${component}_LIBRARY "${FFmpeg_${component}_IMPLIB}") + endif() + + unset(_implib_path) + unset(_bin_path) + unset(_dll_version) +endmacro() + +# FFmpeg_check_version: Macro to help extract version number from FFmpeg headers +macro(FFmpeg_check_version) + if(PC_FFmpeg_${component}_VERSION) + set(FFmpeg_${component}_VERSION ${PC_FFmpeg_${component}_VERSION}) + elseif(EXISTS "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version.h") + if(EXISTS "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version_major.h") + file( + STRINGS "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version_major.h" + _version_string + REGEX "^.*VERSION_MAJOR[ \t]+[0-9]+[ \t]*$" + ) + string(REGEX REPLACE ".*VERSION_MAJOR[ \t]+([0-9]+).*" "\\1" _version_major "${_version_string}") + + file( + STRINGS "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version.h" + _version_string + REGEX "^.*VERSION_(MINOR|MICRO)[ \t]+[0-9]+[ \t]*$" + ) + string(REGEX REPLACE ".*VERSION_MINOR[ \t]+([0-9]+).*" "\\1" _version_minor "${_version_string}") + string(REGEX REPLACE ".*VERSION_MICRO[ \t]+([0-9]+).*" "\\1" _version_patch "${_version_string}") + else() + file( + STRINGS "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version.h" + _version_string + REGEX "^.*VERSION_(MAJOR|MINOR|MICRO)[ \t]+[0-9]+[ \t]*$" + ) + string(REGEX REPLACE ".*VERSION_MAJOR[ \t]+([0-9]+).*" "\\1" _version_major "${_version_string}") + string(REGEX REPLACE ".*VERSION_MINOR[ \t]+([0-9]+).*" "\\1" _version_minor "${_version_string}") + string(REGEX REPLACE ".*VERSION_MICRO[ \t]+([0-9]+).*" "\\1" _version_patch "${_version_string}") + endif() + + set(FFmpeg_${component}_VERSION "${_version_major}.${_version_minor}.${_version_patch}") + unset(_version_major) + unset(_version_minor) + unset(_version_patch) + else() + if(NOT FFmpeg_FIND_QUIETLY) + message(AUTHOR_WARNING "Failed to find ${component_name} version.") + endif() + set(FFmpeg_${component}_VERSION 0.0.0) + endif() +endmacro() + +# FFmpeg_set_soname: Set SONAME property on imported library targets +macro(FFmpeg_set_soname) + if(CMAKE_HOST_SYSTEM_NAME MATCHES "Darwin") + execute_process( + COMMAND sh -c "otool -D '${FFmpeg_${component}_LIBRARY}' | grep -v '${FFmpeg_${component}_LIBRARY}'" + OUTPUT_VARIABLE _output + RESULT_VARIABLE _result + ) + + if(_result EQUAL 0 AND _output MATCHES "^@rpath/") + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_SONAME "${_output}") + endif() + elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD") + execute_process( + COMMAND sh -c "objdump -p '${FFmpeg_${component}_LIBRARY}' | grep SONAME" + OUTPUT_VARIABLE _output + RESULT_VARIABLE _result + ) + + if(_result EQUAL 0) + string(REGEX REPLACE "[ \t]+SONAME[ \t]+([^ \t]+)" "\\1" _soname "${_output}") + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_SONAME "${_soname}") + unset(_soname) + endif() + endif() + unset(_output) + unset(_result) +endmacro() + +foreach(component IN LISTS FFmpeg_FIND_COMPONENTS) + if(NOT component IN_LIST _DEFAULT_COMPONENTS) + message(FATAL_ERROR "Unknown FFmpeg component specified: ${component}.") + endif() + + if(NOT FFmpeg_${component}_FOUND) + ffmpeg_find_component(${component}) + endif() + + if(FFmpeg_${component}_FOUND) + list(APPEND FFmpeg_LIBRARIES ${FFmpeg_${component}_LIBRARY}) + list(APPEND FFmpeg_DEFINITIONS ${FFmpeg_${component}_DEFINITIONS}) + list(APPEND FFmpeg_INCLUDE_DIRS ${FFmpeg_${component}_INCLUDE_DIR}) + endif() +endforeach() + +if(NOT FFmpeg_avutil_FOUND) + ffmpeg_find_component(avutil) +endif() + +if(EXISTS "${FFmpeg_avutil_INCLUDE_DIR}/libavutil/ffversion.h") + file( + STRINGS "${FFmpeg_avutil_INCLUDE_DIR}/libavutil/ffversion.h" + _version_string + REGEX "^.*FFMPEG_VERSION[ \t]+\"n?[0-9a-z\\~+.-]+\"[ \t]*$" + ) + string(REGEX REPLACE ".*FFMPEG_VERSION[ \t]+\"n?([0-9]+\\.[0-9]).*\".*" "\\1" FFmpeg_VERSION "${_version_string}") +endif() + +list(REMOVE_DUPLICATES FFmpeg_INCLUDE_DIRS) +list(REMOVE_DUPLICATES FFmpeg_LIBRARIES) +list(REMOVE_DUPLICATES FFmpeg_DEFINITIONS) + +if(CMAKE_HOST_SYSTEM_NAME MATCHES "Darwin|Windows") + set(FFmpeg_ERROR_REASON "Ensure that obs-deps is provided as part of CMAKE_PREFIX_PATH.") +elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD") + set(FFmpeg_ERROR_REASON "Ensure that required FFmpeg libraries are installed on the system.") +endif() + +find_package_handle_standard_args( + FFmpeg + REQUIRED_VARS FFmpeg_LIBRARIES FFmpeg_INCLUDE_DIRS + VERSION_VAR FFmpeg_VERSION + HANDLE_COMPONENTS + REASON_FAILURE_MESSAGE "${FFmpeg_ERROR_REASON}" +) + +if(FFmpeg_FOUND AND NOT TARGET FFmpeg::FFmpeg) + add_library(FFmpeg::FFmpeg INTERFACE IMPORTED) +endif() + +foreach(component IN LISTS FFmpeg_FIND_COMPONENTS) + if(FFmpeg_${component}_FOUND AND NOT TARGET FFmpeg::${component}) + if(IS_ABSOLUTE "${FFmpeg_${component}_LIBRARY}") + if(DEFINED FFmpeg_${component}_IMPLIB) + if(FFmpeg_${component}_IMPLIB STREQUAL FFmpeg_${component}_LIBRARY) + add_library(FFmpeg::${component} STATIC IMPORTED) + else() + add_library(FFmpeg::${component} SHARED IMPORTED) + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_IMPLIB "${FFmpeg_${component}_IMPLIB}") + endif() + else() + add_library(FFmpeg::${component} UNKNOWN IMPORTED) + ffmpeg_set_soname() + endif() + + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_LOCATION "${FFmpeg_${component}_LIBRARY}") + else() + add_library(FFmpeg::${component} INTERFACE IMPORTED) + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_LIBNAME "${FFmpeg_${component}_LIBRARY}") + endif() + set_target_properties( + FFmpeg::${component} + PROPERTIES + INTERFACE_COMPILE_OPTIONS "${PC_FFmpeg_${component}_CFLAGS_OTHER}" + INTERFACE_INCLUDE_DIRECTORIES "${FFmpeg_${component}_INCLUDE_DIR}" + VERSION ${FFmpeg_${component}_VERSION} + ) + + get_target_property(_ffmpeg_interface_libraries FFmpeg::FFmpeg INTERFACE_LINK_LIBRARIES) + if(NOT FFmpeg::${component} IN_LIST _ffmpeg_interface_libraries) + set_property(TARGET FFmpeg::FFmpeg APPEND PROPERTY INTERFACE_LINK_LIBRARIES FFmpeg::${component}) + endif() + endif() +endforeach() + +include(FeatureSummary) +set_package_properties( + FFmpeg + PROPERTIES + URL "https://www.ffmpeg.org" + DESCRIPTION "A complete, cross-platform solution to record, convert and stream audio and video." +) diff --git a/cpp/obs/cmake/common/bootstrap.cmake b/cpp/obs/cmake/common/bootstrap.cmake new file mode 100644 index 0000000000..c38b7d029a --- /dev/null +++ b/cpp/obs/cmake/common/bootstrap.cmake @@ -0,0 +1,97 @@ +# Plugin bootstrap module + +include_guard(GLOBAL) + +# Map fallback configurations for optimized build configurations +# gersemi: off +set( + CMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO + RelWithDebInfo + Release + MinSizeRel + None + "" +) +set( + CMAKE_MAP_IMPORTED_CONFIG_MINSIZEREL + MinSizeRel + Release + RelWithDebInfo + None + "" +) +set( + CMAKE_MAP_IMPORTED_CONFIG_RELEASE + Release + RelWithDebInfo + MinSizeRel + None + "" +) +# gersemi: on + +# Prohibit in-source builds +if("${CMAKE_CURRENT_BINARY_DIR}" STREQUAL "${CMAKE_CURRENT_SOURCE_DIR}") + message( + FATAL_ERROR + "In-source builds are not supported. " + "Specify a build directory via 'cmake -S -B ' instead." + ) + file(REMOVE_RECURSE "${CMAKE_CURRENT_SOURCE_DIR}/CMakeCache.txt" "${CMAKE_CURRENT_SOURCE_DIR}/CMakeFiles") +endif() + +# Add common module directories to default search path +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/common") + +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec) + +string(JSON _name GET ${buildspec} name) +string(JSON _website GET ${buildspec} website) +string(JSON _author GET ${buildspec} author) +string(JSON _email GET ${buildspec} email) +string(JSON _version GET ${buildspec} version) +string(JSON _bundleId GET ${buildspec} platformConfig macos bundleId) + +# Release CI builds the plugin against a specific libmoq release and versions it +# to match (see build.sh --libmoq-release), so allow overriding the buildspec +# version. Everything below derives PLUGIN_VERSION_* from _version. +if(DEFINED PLUGIN_VERSION_OVERRIDE AND NOT PLUGIN_VERSION_OVERRIDE STREQUAL "") + set(_version "${PLUGIN_VERSION_OVERRIDE}") +endif() + +set(PLUGIN_AUTHOR ${_author}) +set(PLUGIN_WEBSITE ${_website}) +set(PLUGIN_EMAIL ${_email}) +set(PLUGIN_VERSION ${_version}) +set(MACOS_BUNDLEID ${_bundleId}) + +string(REPLACE "." ";" _version_canonical "${_version}") +list(GET _version_canonical 0 PLUGIN_VERSION_MAJOR) +list(GET _version_canonical 1 PLUGIN_VERSION_MINOR) +list(GET _version_canonical 2 PLUGIN_VERSION_PATCH) +unset(_version_canonical) + +include(buildnumber) +include(osconfig) + +# Allow selection of common build types via UI +if(NOT CMAKE_GENERATOR MATCHES "(Xcode|Visual Studio .+)") + if(NOT CMAKE_BUILD_TYPE) + set( + CMAKE_BUILD_TYPE + "RelWithDebInfo" + CACHE STRING + "OBS build type [Release, RelWithDebInfo, Debug, MinSizeRel]" + FORCE + ) + set_property( + CACHE CMAKE_BUILD_TYPE + PROPERTY STRINGS Release RelWithDebInfo Debug MinSizeRel + ) + endif() +endif() + +# Disable exports automatically going into the CMake package registry +set(CMAKE_EXPORT_PACKAGE_REGISTRY FALSE) +# Enable default inclusion of targets' source and binary directory +set(CMAKE_INCLUDE_CURRENT_DIR TRUE) diff --git a/cpp/obs/cmake/common/buildnumber.cmake b/cpp/obs/cmake/common/buildnumber.cmake new file mode 100644 index 0000000000..cc904496b2 --- /dev/null +++ b/cpp/obs/cmake/common/buildnumber.cmake @@ -0,0 +1,33 @@ +# CMake build number module + +include_guard(GLOBAL) + +# Define build number cache file +set( + _BUILD_NUMBER_CACHE + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/.CMakeBuildNumber" + CACHE INTERNAL + "OBS build number cache file" +) + +# Read build number from cache file or manual override +if(NOT DEFINED PLUGIN_BUILD_NUMBER) + if(EXISTS "${_BUILD_NUMBER_CACHE}") + file(READ "${_BUILD_NUMBER_CACHE}" PLUGIN_BUILD_NUMBER) + math(EXPR PLUGIN_BUILD_NUMBER "${PLUGIN_BUILD_NUMBER}+1") + else() + if("$ENV{CI}") + if("$ENV{GITHUB_RUN_ID}") + set(PLUGIN_BUILD_NUMBER "$ENV{GITHUB_RUN_ID}") + elseif("$ENV{GITLAB_RUN_ID}") + set(PLUGIN_BUILD_NUMBER "$ENV{GITLAB_RUN_ID}") + else() + set(PLUGIN_BUILD_NUMBER "1") + endif() + else() + # Local builds without an existing cache default to 1 + set(PLUGIN_BUILD_NUMBER "1") + endif() + endif() + file(WRITE "${_BUILD_NUMBER_CACHE}" "${PLUGIN_BUILD_NUMBER}") +endif() diff --git a/cpp/obs/cmake/common/buildspec_common.cmake b/cpp/obs/cmake/common/buildspec_common.cmake new file mode 100644 index 0000000000..59212c38f8 --- /dev/null +++ b/cpp/obs/cmake/common/buildspec_common.cmake @@ -0,0 +1,226 @@ +# Common build dependencies module + +include_guard(GLOBAL) + +# _check_deps_version: Checks for obs-deps VERSION file in prefix paths +function(_check_deps_version version) + set(found FALSE) + + foreach(path IN LISTS CMAKE_PREFIX_PATH) + if(EXISTS "${path}/share/obs-deps/VERSION") + if(dependency STREQUAL qt6 AND NOT EXISTS "${path}/lib/cmake/Qt6/Qt6Config.cmake") + set(found FALSE) + continue() + endif() + + file(READ "${path}/share/obs-deps/VERSION" _check_version) + string(REPLACE "\n" "" _check_version "${_check_version}") + string(REPLACE "-" "." _check_version "${_check_version}") + string(REPLACE "-" "." version "${version}") + + if(_check_version VERSION_EQUAL version) + set(found TRUE) + break() + elseif(_check_version VERSION_LESS version) + message( + AUTHOR_WARNING + "Older ${label} version detected in ${path}: \n" + "Found ${_check_version}, require ${version}" + ) + list(REMOVE_ITEM CMAKE_PREFIX_PATH "${path}") + list(APPEND CMAKE_PREFIX_PATH "${path}") + set(CMAKE_PREFIX_PATH ${CMAKE_PREFIX_PATH}) + continue() + else() + message( + AUTHOR_WARNING + "Newer ${label} version detected in ${path}: \n" + "Found ${_check_version}, require ${version}" + ) + set(found TRUE) + break() + endif() + endif() + endforeach() + + return(PROPAGATE found CMAKE_PREFIX_PATH) +endfunction() + +# _setup_obs_studio: Create obs-studio build project, then build libobs and obs-frontend-api +function(_setup_obs_studio) + if(NOT libobs_DIR) + set(_is_fresh --fresh) + endif() + + if(OS_WINDOWS) + set(_cmake_generator "${CMAKE_GENERATOR}") + set(_cmake_arch "-A ${arch},version=${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION}") + set(_cmake_extra "-DCMAKE_SYSTEM_VERSION=${CMAKE_SYSTEM_VERSION} -DCMAKE_ENABLE_SCRIPTING=OFF") + elseif(OS_MACOS) + set(_cmake_generator "Xcode") + set(_cmake_arch "-DCMAKE_OSX_ARCHITECTURES:STRING='arm64;x86_64'") + set(_cmake_extra "-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() + + message(STATUS "Configure ${label} (${arch})") + execute_process( + COMMAND + "${CMAKE_COMMAND}" -S "${dependencies_dir}/${_obs_destination}" -B + "${dependencies_dir}/${_obs_destination}/build_${arch}" -G ${_cmake_generator} "${_cmake_arch}" + -DOBS_CMAKE_VERSION:STRING=3.0.0 -DENABLE_PLUGINS:BOOL=OFF -DENABLE_FRONTEND:BOOL=OFF + -DOBS_VERSION_OVERRIDE:STRING=${_obs_version} "-DCMAKE_PREFIX_PATH='${CMAKE_PREFIX_PATH}'" ${_is_fresh} + ${_cmake_extra} + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + message(STATUS "Configure ${label} (${arch}) - done") + + message(STATUS "Build ${label} (Debug - ${arch})") + execute_process( + COMMAND "${CMAKE_COMMAND}" --build build_${arch} --target obs-frontend-api --config Debug --parallel + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + message(STATUS "Build ${label} (Debug - ${arch}) - done") + + message(STATUS "Build ${label} (Release - ${arch})") + execute_process( + COMMAND "${CMAKE_COMMAND}" --build build_${arch} --target obs-frontend-api --config Release --parallel + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + message(STATUS "Build ${label} (Reelase - ${arch}) - done") + + message(STATUS "Install ${label} (${arch})") + execute_process( + COMMAND + "${CMAKE_COMMAND}" --install build_${arch} --component Development --config Debug --prefix "${dependencies_dir}" + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + execute_process( + COMMAND + "${CMAKE_COMMAND}" --install build_${arch} --component Development --config Release --prefix "${dependencies_dir}" + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + message(STATUS "Install ${label} (${arch}) - done") +endfunction() + +# _check_dependencies: Fetch and extract pre-built OBS build dependencies +function(_check_dependencies) + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec) + + string(JSON dependency_data GET ${buildspec} dependencies) + + foreach(dependency IN LISTS dependencies_list) + string(JSON data GET ${dependency_data} ${dependency}) + string(JSON version GET ${data} version) + string(JSON hash GET ${data} hashes ${platform}) + string(JSON url GET ${data} baseUrl) + string(JSON label GET ${data} label) + string(JSON revision ERROR_VARIABLE error GET ${data} revision ${platform}) + + message(STATUS "Setting up ${label} (${arch})") + + set(file "${${dependency}_filename}") + set(destination "${${dependency}_destination}") + string(REPLACE "VERSION" "${version}" file "${file}") + string(REPLACE "VERSION" "${version}" destination "${destination}") + string(REPLACE "ARCH" "${arch}" file "${file}") + string(REPLACE "ARCH" "${arch}" destination "${destination}") + if(revision) + string(REPLACE "_REVISION" "_v${revision}" file "${file}") + string(REPLACE "-REVISION" "-v${revision}" file "${file}") + else() + string(REPLACE "_REVISION" "" file "${file}") + string(REPLACE "-REVISION" "" file "${file}") + endif() + + if(EXISTS "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256") + file( + READ "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256" + OBS_DEPENDENCY_${dependency}_${arch}_HASH + ) + endif() + + set(skip FALSE) + if(dependency STREQUAL prebuilt OR dependency STREQUAL qt6) + if(OBS_DEPENDENCY_${dependency}_${arch}_HASH STREQUAL ${hash}) + _check_deps_version(${version}) + + if(found) + set(skip TRUE) + endif() + endif() + endif() + + if(skip) + message(STATUS "Setting up ${label} (${arch}) - skipped") + continue() + endif() + + if(dependency STREQUAL obs-studio) + set(url ${url}/${file}) + else() + set(url ${url}/${version}/${file}) + endif() + + if(NOT EXISTS "${dependencies_dir}/${file}") + message(STATUS "Downloading ${url}") + file(DOWNLOAD "${url}" "${dependencies_dir}/${file}" STATUS download_status EXPECTED_HASH SHA256=${hash}) + + list(GET download_status 0 error_code) + list(GET download_status 1 error_message) + if(error_code GREATER 0) + message(STATUS "Downloading ${url} - Failure") + message(FATAL_ERROR "Unable to download ${url}, failed with error: ${error_message}") + file(REMOVE "${dependencies_dir}/${file}") + else() + message(STATUS "Downloading ${url} - done") + endif() + endif() + + if(NOT OBS_DEPENDENCY_${dependency}_${arch}_HASH STREQUAL ${hash}) + file(REMOVE_RECURSE "${dependencies_dir}/${destination}") + endif() + + if(NOT EXISTS "${dependencies_dir}/${destination}") + file(MAKE_DIRECTORY "${dependencies_dir}/${destination}") + if(dependency STREQUAL obs-studio) + file(ARCHIVE_EXTRACT INPUT "${dependencies_dir}/${file}" DESTINATION "${dependencies_dir}") + else() + file(ARCHIVE_EXTRACT INPUT "${dependencies_dir}/${file}" DESTINATION "${dependencies_dir}/${destination}") + endif() + endif() + + file(WRITE "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256" "${hash}") + + if(dependency STREQUAL prebuilt) + list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}") + elseif(dependency STREQUAL qt6) + list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}") + elseif(dependency STREQUAL obs-studio) + set(_obs_version ${version}) + set(_obs_destination "${destination}") + list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}") + endif() + + message(STATUS "Setting up ${label} (${arch}) - done") + endforeach() + + list(REMOVE_DUPLICATES CMAKE_PREFIX_PATH) + + set(CMAKE_PREFIX_PATH ${CMAKE_PREFIX_PATH} CACHE PATH "CMake prefix search path" FORCE) + + _setup_obs_studio() +endfunction() diff --git a/cpp/obs/cmake/common/ccache.cmake b/cpp/obs/cmake/common/ccache.cmake new file mode 100644 index 0000000000..577279e0ca --- /dev/null +++ b/cpp/obs/cmake/common/ccache.cmake @@ -0,0 +1,25 @@ +# OBS CMake ccache module + +include_guard(GLOBAL) + +if(NOT DEFINED CCACHE_PROGRAM) + message(DEBUG "Trying to find ccache on build host") + find_program(CCACHE_PROGRAM "ccache") + mark_as_advanced(CCACHE_PROGRAM) +endif() + +if(CCACHE_PROGRAM) + message(DEBUG "Trying to find ccache on build host - done") + message(DEBUG "Ccache found as ${CCACHE_PROGRAM}") + option(ENABLE_CCACHE "Enable compiler acceleration with ccache" OFF) + + if(ENABLE_CCACHE) + set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_OBJC_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_OBJCXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_CUDA_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + endif() +else() + message(DEBUG "Trying to find ccache on build host - skipped") +endif() diff --git a/cpp/obs/cmake/common/compiler_common.cmake b/cpp/obs/cmake/common/compiler_common.cmake new file mode 100644 index 0000000000..fcd256892f --- /dev/null +++ b/cpp/obs/cmake/common/compiler_common.cmake @@ -0,0 +1,83 @@ +# CMake common compiler options module + +include_guard(GLOBAL) + +# Set C and C++ language standards to C17 and C++17 +set(CMAKE_C_STANDARD 17) +set(CMAKE_C_STANDARD_REQUIRED TRUE) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED TRUE) + +# Set symbols to be hidden by default for C and C++ +set(CMAKE_C_VISIBILITY_PRESET hidden) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN TRUE) + +# clang options for C, C++, ObjC, and ObjC++ +set( + _obs_clang_common_options + -fno-strict-aliasing + -Wno-trigraphs + -Wno-missing-field-initializers + -Wno-missing-prototypes + -Werror=return-type + -Wunreachable-code + -Wquoted-include-in-framework-header + -Wno-missing-braces + -Wparentheses + -Wswitch + -Wno-unused-function + -Wno-unused-label + -Wunused-parameter + -Wunused-variable + -Wunused-value + -Wempty-body + -Wuninitialized + -Wno-unknown-pragmas + -Wfour-char-constants + -Wconstant-conversion + -Wno-conversion + -Wint-conversion + -Wbool-conversion + -Wenum-conversion + -Wnon-literal-null-conversion + -Wsign-compare + -Wshorten-64-to-32 + -Wpointer-sign + -Wnewline-eof + -Wno-implicit-fallthrough + -Wdeprecated-declarations + -Wno-sign-conversion + -Winfinite-recursion + -Wcomma + -Wno-strict-prototypes + -Wno-semicolon-before-method-body + -Wformat-security + -Wvla + -Wno-error=shorten-64-to-32 +) + +# clang options for C +set(_obs_clang_c_options ${_obs_clang_common_options} -Wno-shadow -Wno-float-conversion) + +# clang options for C++ +set( + _obs_clang_cxx_options + ${_obs_clang_common_options} + -Wno-non-virtual-dtor + -Wno-overloaded-virtual + -Wno-exit-time-destructors + -Wno-shadow + -Winvalid-offsetof + -Wmove + -Werror=block-capture-autoreleasing + -Wrange-loop-analysis +) + +if(CMAKE_CXX_STANDARD GREATER_EQUAL 20) + list(APPEND _obs_clang_cxx_options -fno-char8_t) +endif() + +if(NOT DEFINED CMAKE_COMPILE_WARNING_AS_ERROR) + set(CMAKE_COMPILE_WARNING_AS_ERROR ON) +endif() diff --git a/cpp/obs/cmake/common/helpers_common.cmake b/cpp/obs/cmake/common/helpers_common.cmake new file mode 100644 index 0000000000..3dc3b906cd --- /dev/null +++ b/cpp/obs/cmake/common/helpers_common.cmake @@ -0,0 +1,49 @@ +# CMake common helper functions module + +include_guard(GLOBAL) + +# check_uuid: Helper function to check for valid UUID +function(check_uuid uuid_string return_value) + set(valid_uuid TRUE) + # gersemi: off + set(uuid_token_lengths 8 4 4 4 12) + # gersemi: on + set(token_num 0) + + string(REPLACE "-" ";" uuid_tokens ${uuid_string}) + list(LENGTH uuid_tokens uuid_num_tokens) + + if(uuid_num_tokens EQUAL 5) + message(DEBUG "UUID ${uuid_string} is valid with 5 tokens.") + foreach(uuid_token IN LISTS uuid_tokens) + list(GET uuid_token_lengths ${token_num} uuid_target_length) + string(LENGTH "${uuid_token}" uuid_actual_length) + if(uuid_actual_length EQUAL uuid_target_length) + string(REGEX MATCH "[0-9a-fA-F]+" uuid_hex_match ${uuid_token}) + if(NOT uuid_hex_match STREQUAL uuid_token) + set(valid_uuid FALSE) + break() + endif() + else() + set(valid_uuid FALSE) + break() + endif() + math(EXPR token_num "${token_num}+1") + endforeach() + else() + set(valid_uuid FALSE) + endif() + message(DEBUG "UUID ${uuid_string} valid: ${valid_uuid}") + set(${return_value} ${valid_uuid} PARENT_SCOPE) +endfunction() + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/plugin-support.c.in") + configure_file(src/plugin-support.c.in plugin-support.c @ONLY) + add_library(plugin-support STATIC) + target_sources(plugin-support PRIVATE plugin-support.c PUBLIC src/plugin-support.h) + target_include_directories(plugin-support PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") + if(OS_LINUX OR OS_FREEBSD OR OS_OPENBSD) + # add fPIC on Linux to prevent shared object errors + set_property(TARGET plugin-support PROPERTY POSITION_INDEPENDENT_CODE ON) + endif() +endif() diff --git a/cpp/obs/cmake/common/osconfig.cmake b/cpp/obs/cmake/common/osconfig.cmake new file mode 100644 index 0000000000..87d435a013 --- /dev/null +++ b/cpp/obs/cmake/common/osconfig.cmake @@ -0,0 +1,20 @@ +# CMake operating system bootstrap module + +include_guard(GLOBAL) + +if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + set(CMAKE_C_EXTENSIONS FALSE) + set(CMAKE_CXX_EXTENSIONS FALSE) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/windows") + set(OS_WINDOWS TRUE) +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + set(CMAKE_C_EXTENSIONS FALSE) + set(CMAKE_CXX_EXTENSIONS FALSE) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos") + set(OS_MACOS TRUE) +elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD|OpenBSD") + set(CMAKE_CXX_EXTENSIONS FALSE) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/linux") + string(TOUPPER "${CMAKE_HOST_SYSTEM_NAME}" _SYSTEM_NAME_U) + set(OS_${_SYSTEM_NAME_U} TRUE) +endif() diff --git a/cpp/obs/cmake/linux/compilerconfig.cmake b/cpp/obs/cmake/linux/compilerconfig.cmake new file mode 100644 index 0000000000..c30b2f4f16 --- /dev/null +++ b/cpp/obs/cmake/linux/compilerconfig.cmake @@ -0,0 +1,78 @@ +# CMake Linux compiler configuration module + +include_guard(GLOBAL) + +include(ccache) +include(compiler_common) + +option(ENABLE_COMPILER_TRACE "Enable Clang time-trace (required Clang and Ninja)" OFF) +mark_as_advanced(ENABLE_COMPILER_TRACE) + +# gcc options for C +set( + _obs_gcc_c_options + -fno-strict-aliasing + -fopenmp-simd + -Wdeprecated-declarations + -Wempty-body + -Wenum-conversion + -Werror=return-type + -Wextra + -Wformat + -Wformat-security + -Wno-conversion + -Wno-float-conversion + -Wno-implicit-fallthrough + -Wno-missing-braces + -Wno-missing-field-initializers + -Wno-shadow + -Wno-sign-conversion + -Wno-trigraphs + -Wno-unknown-pragmas + -Wno-unused-function + -Wno-unused-label + -Wparentheses + -Wuninitialized + -Wunreachable-code + -Wunused-parameter + -Wunused-value + -Wunused-variable + -Wvla +) + +add_compile_options( + -fopenmp-simd + "$<$:${_obs_gcc_c_options}>" + "$<$:-Wint-conversion;-Wno-missing-prototypes;-Wno-strict-prototypes;-Wpointer-sign>" + "$<$:${_obs_gcc_c_options}>" + "$<$:-Winvalid-offsetof;-Wno-overloaded-virtual>" + "$<$:${_obs_clang_c_options}>" + "$<$:${_obs_clang_cxx_options}>" +) + +if(CMAKE_CXX_COMPILER_ID STREQUAL GNU) + # * Disable false-positive warning in GCC 12.1.0 and later + # * https://gcc.gnu.org/bugzilla/show_bug.cgi?id=105562 + if(CMAKE_C_COMPILER_VERSION VERSION_GREATER_EQUAL 12.1.0) + add_compile_options(-Wno-error=maybe-uninitialized) + endif() + + # * Add warning for infinite recursion (added in GCC 12) + # * Also disable warnings for stringop-overflow due to https://gcc.gnu.org/bugzilla/show_bug.cgi?id=106297 + if(CMAKE_C_COMPILER_VERSION VERSION_GREATER_EQUAL 12.0.0) + add_compile_options(-Winfinite-recursion -Wno-stringop-overflow) + endif() + + if(CMAKE_SYSTEM_PROCESSOR STREQUAL aarch64) + add_compile_options(-Wno-error=type-limits) + endif() +endif() + +# Enable compiler and build tracing (requires Ninja generator) +if(ENABLE_COMPILER_TRACE AND CMAKE_GENERATOR STREQUAL "Ninja") + add_compile_options($<$:-ftime-trace> $<$:-ftime-trace>) +else() + set(ENABLE_COMPILER_TRACE OFF CACHE STRING "Enable Clang time-trace (required Clang and Ninja)" FORCE) +endif() + +add_compile_definitions($<$:DEBUG> $<$:_DEBUG> SIMDE_ENABLE_OPENMP) diff --git a/cpp/obs/cmake/linux/defaults.cmake b/cpp/obs/cmake/linux/defaults.cmake new file mode 100644 index 0000000000..f38b44ab11 --- /dev/null +++ b/cpp/obs/cmake/linux/defaults.cmake @@ -0,0 +1,88 @@ +# CMake Linux defaults module + +include_guard(GLOBAL) + +# Set default installation directories +include(GNUInstallDirs) + +if(CMAKE_INSTALL_LIBDIR MATCHES "(CMAKE_SYSTEM_PROCESSOR)") + string(REPLACE "CMAKE_SYSTEM_PROCESSOR" "${CMAKE_SYSTEM_PROCESSOR}" CMAKE_INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}") +endif() + +# Enable find_package targets to become globally available targets +set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL TRUE) + +set(CPACK_PACKAGE_NAME "${CMAKE_PROJECT_NAME}") +set(CPACK_PACKAGE_VERSION "${CMAKE_PROJECT_VERSION}") +set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-${CMAKE_C_LIBRARY_ARCHITECTURE}") + +set(CPACK_GENERATOR "DEB") +set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) +set(CPACK_DEBIAN_PACKAGE_MAINTAINER "${PLUGIN_EMAIL}") +set(CPACK_SET_DESTDIR ON) + +if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.25.0 OR NOT CMAKE_CROSSCOMPILING) + set(CPACK_DEBIAN_DEBUGINFO_PACKAGE ON) +endif() + +set(CPACK_OUTPUT_FILE_PREFIX "${CMAKE_CURRENT_SOURCE_DIR}/release") + +set(CPACK_SOURCE_GENERATOR "TXZ") +set( + CPACK_SOURCE_IGNORE_FILES + ".*~$" + \\.git/ + \\.github/ + \\.gitignore + \\.ccache/ + build_.* + cmake/\\.CMakeBuildNumber + release/ +) + +set(CPACK_VERBATIM_VARIABLES YES) +set(CPACK_SOURCE_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-source") +set(CPACK_ARCHIVE_THREADS 0) + +include(CPack) + +find_package(libobs QUIET) + +if(NOT TARGET OBS::libobs) + find_package(LibObs REQUIRED) + add_library(OBS::libobs ALIAS libobs) + + if(ENABLE_FRONTEND_API) + find_path( + obs-frontend-api_INCLUDE_DIR + NAMES obs-frontend-api.h + PATHS /usr/include /usr/local/include + PATH_SUFFIXES obs + ) + + find_library(obs-frontend-api_LIBRARY NAMES obs-frontend-api PATHS /usr/lib /usr/local/lib) + + if(obs-frontend-api_LIBRARY) + if(NOT TARGET OBS::obs-frontend-api) + if(IS_ABSOLUTE "${obs-frontend-api_LIBRARY}") + add_library(OBS::obs-frontend-api UNKNOWN IMPORTED) + set_property(TARGET OBS::obs-frontend-api PROPERTY IMPORTED_LOCATION "${obs-frontend-api_LIBRARY}") + else() + add_library(OBS::obs-frontend-api INTERFACE IMPORTED) + set_property(TARGET OBS::obs-frontend-api PROPERTY IMPORTED_LIBNAME "${obs-frontend-api_LIBRARY}") + endif() + + set_target_properties( + OBS::obs-frontend-api + PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${obs-frontend-api_INCLUDE_DIR}" + ) + endif() + endif() + endif() + + macro(find_package) + if(NOT "${ARGV0}" STREQUAL libobs AND NOT "${ARGV0}" STREQUAL obs-frontend-api) + _find_package(${ARGV}) + endif() + endmacro() +endif() diff --git a/cpp/obs/cmake/linux/helpers.cmake b/cpp/obs/cmake/linux/helpers.cmake new file mode 100644 index 0000000000..5b7b845313 --- /dev/null +++ b/cpp/obs/cmake/linux/helpers.cmake @@ -0,0 +1,105 @@ +# CMake Linux helper functions module + +include_guard(GLOBAL) + +include(helpers_common) + +# set_target_properties_plugin: Set target properties for use in obs-studio +function(set_target_properties_plugin target) + set(options "") + set(oneValueArgs "") + set(multiValueArgs PROPERTIES) + cmake_parse_arguments(PARSE_ARGV 0 _STPO "${options}" "${oneValueArgs}" "${multiValueArgs}") + + message(DEBUG "Setting additional properties for target ${target}...") + + while(_STPO_PROPERTIES) + list(POP_FRONT _STPO_PROPERTIES key value) + set_property(TARGET ${target} PROPERTY ${key} "${value}") + endwhile() + + set_target_properties( + ${target} + PROPERTIES VERSION ${PLUGIN_VERSION} SOVERSION ${PLUGIN_VERSION_MAJOR} PREFIX "" + ) + + install( + TARGETS ${target} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/obs-plugins + ) + + if(TARGET plugin-support) + target_link_libraries(${target} PRIVATE plugin-support) + endif() + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different "$" "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMENT "Copy ${target} to rundir" + VERBATIM + ) + + target_install_resources(${target}) + + get_target_property(target_sources ${target} SOURCES) + set(target_ui_files ${target_sources}) + list(FILTER target_ui_files INCLUDE REGEX ".+\\.(ui|qrc)") + source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" PREFIX "UI Files" FILES ${target_ui_files}) +endfunction() + +# Helper function to add resources into bundle +function(target_install_resources target) + message(DEBUG "Installing resources for target ${target}...") + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/data") + file(GLOB_RECURSE data_files "${CMAKE_CURRENT_SOURCE_DIR}/data/*") + foreach(data_file IN LISTS data_files) + cmake_path( + RELATIVE_PATH data_file + BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" + OUTPUT_VARIABLE relative_path + ) + cmake_path(GET relative_path PARENT_PATH relative_path) + target_sources(${target} PRIVATE "${data_file}") + source_group("Resources/${relative_path}" FILES "${data_file}") + endforeach() + + install( + DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" + DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/obs/obs-plugins/${target} + USE_SOURCE_PERMISSIONS + ) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMAND + "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/data" + "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMENT "Copy ${target} resources to rundir" + VERBATIM + ) + endif() +endfunction() + +# Helper function to add a specific resource to a bundle +function(target_add_resource target resource) + message(DEBUG "Add resource '${resource}' to target ${target} at destination '${target_destination}'...") + + install(FILES "${resource}" DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/obs/obs-plugins/${target}) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMAND "${CMAKE_COMMAND}" -E copy "${resource}" "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMENT "Copy ${target} resource ${resource} to rundir" + VERBATIM + ) + + source_group("Resources" FILES "${resource}") +endfunction() diff --git a/cpp/obs/cmake/macos/buildspec.cmake b/cpp/obs/cmake/macos/buildspec.cmake new file mode 100644 index 0000000000..a86e4d8cf8 --- /dev/null +++ b/cpp/obs/cmake/macos/buildspec.cmake @@ -0,0 +1,37 @@ +# CMake macOS build dependencies module + +include_guard(GLOBAL) + +include(buildspec_common) + +# _check_dependencies_macos: Set up macOS slice for _check_dependencies +function(_check_dependencies_macos) + set(arch universal) + set(platform macos) + + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec) + + set(dependencies_dir "${CMAKE_CURRENT_SOURCE_DIR}/.deps") + set(prebuilt_filename "macos-deps-VERSION-ARCH_REVISION.tar.xz") + set(prebuilt_destination "obs-deps-VERSION-ARCH") + set(qt6_filename "macos-deps-qt6-VERSION-ARCH-REVISION.tar.xz") + set(qt6_destination "obs-deps-qt6-VERSION-ARCH") + set(obs-studio_filename "VERSION.tar.gz") + set(obs-studio_destination "obs-studio-VERSION") + set(dependencies_list prebuilt qt6 obs-studio) + + _check_dependencies() + + # Best-effort: strip Gatekeeper quarantine from the downloaded deps. Not all + # files carry the attribute (and build outputs under .deps may be read-only), + # so a non-zero exit here is expected and must not abort configuration. + execute_process( + COMMAND "xattr" -r -d com.apple.quarantine "${dependencies_dir}" + RESULT_VARIABLE result + ) + + list(APPEND CMAKE_FRAMEWORK_PATH "${dependencies_dir}/Frameworks") + set(CMAKE_FRAMEWORK_PATH ${CMAKE_FRAMEWORK_PATH} PARENT_SCOPE) +endfunction() + +_check_dependencies_macos() diff --git a/cpp/obs/cmake/macos/compilerconfig.cmake b/cpp/obs/cmake/macos/compilerconfig.cmake new file mode 100644 index 0000000000..475e8ee487 --- /dev/null +++ b/cpp/obs/cmake/macos/compilerconfig.cmake @@ -0,0 +1,103 @@ +# CMake macOS compiler configuration module + +include_guard(GLOBAL) + +option(ENABLE_COMPILER_TRACE "Enable clang time-trace" OFF) +mark_as_advanced(ENABLE_COMPILER_TRACE) + +if(NOT XCODE) + message(FATAL_ERROR "Building OBS Studio on macOS requires Xcode generator.") +endif() + +include(ccache) +include(compiler_common) + +add_compile_options("$<$>:-fopenmp-simd>") + +# Ensure recent enough Xcode and platform SDK +function(check_sdk_requirements) + set(obs_macos_minimum_sdk 15.0) # Keep in sync with Xcode + set(obs_macos_minimum_xcode 16.0) # Keep in sync with SDK + execute_process( + COMMAND xcrun --sdk macosx --show-sdk-platform-version + OUTPUT_VARIABLE obs_macos_current_sdk + RESULT_VARIABLE result + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if(NOT result EQUAL 0) + message( + FATAL_ERROR + "Failed to fetch macOS SDK version. " + "Ensure that the macOS SDK is installed and that xcode-select points at the Xcode developer directory." + ) + endif() + message(DEBUG "macOS SDK version: ${obs_macos_current_sdk}") + if(obs_macos_current_sdk VERSION_LESS obs_macos_minimum_sdk) + message( + FATAL_ERROR + "Your macOS SDK version (${obs_macos_current_sdk}) is too low. " + "The macOS ${obs_macos_minimum_sdk} SDK (Xcode ${obs_macos_minimum_xcode}) is required to build OBS." + ) + endif() + execute_process(COMMAND xcrun --find xcodebuild OUTPUT_VARIABLE obs_macos_xcodebuild RESULT_VARIABLE result) + if(NOT result EQUAL 0) + message( + FATAL_ERROR + "Xcode was not found. " + "Ensure you have installed Xcode and that xcode-select points at the Xcode developer directory." + ) + endif() + message(DEBUG "Path to xcodebuild binary: ${obs_macos_xcodebuild}") + if(XCODE_VERSION VERSION_LESS obs_macos_minimum_xcode) + message( + FATAL_ERROR + "Your Xcode version (${XCODE_VERSION}) is too low. Xcode ${obs_macos_minimum_xcode} is required to build OBS." + ) + endif() +endfunction() + +check_sdk_requirements() + +# Enable dSYM generator for release builds +string(APPEND CMAKE_C_FLAGS_RELEASE " -g") +string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g") +string(APPEND CMAKE_OBJC_FLAGS_RELEASE " -g") +string(APPEND CMAKE_OBJCXX_FLAGS_RELEASE " -g") + +# Default ObjC compiler options used by Xcode: +# +# * -Wno-implicit-atomic-properties +# * -Wno-objc-interface-ivars +# * -Warc-repeated-use-of-weak +# * -Wno-arc-maybe-repeated-use-of-weak +# * -Wimplicit-retain-self +# * -Wduplicate-method-match +# * -Wshadow +# * -Wfloat-conversion +# * -Wobjc-literal-conversion +# * -Wno-selector +# * -Wno-strict-selector-match +# * -Wundeclared-selector +# * -Wdeprecated-implementations +# * -Wprotocol +# * -Werror=block-capture-autoreleasing +# * -Wrange-loop-analysis + +# Default ObjC++ compiler options used by Xcode: +# +# * -Wno-non-virtual-dtor + +add_compile_definitions( + $<$>:$<$:DEBUG>> + $<$>:$<$:_DEBUG>> + $<$>:SIMDE_ENABLE_OPENMP> +) + +if(ENABLE_COMPILER_TRACE) + add_compile_options( + $<$>:-ftime-trace> + "$<$:SHELL:-Xfrontend -debug-time-expression-type-checking>" + "$<$:SHELL:-Xfrontend -debug-time-function-bodies>" + ) + add_link_options(LINKER:-print_statistics) +endif() diff --git a/cpp/obs/cmake/macos/defaults.cmake b/cpp/obs/cmake/macos/defaults.cmake new file mode 100644 index 0000000000..bf00c2c199 --- /dev/null +++ b/cpp/obs/cmake/macos/defaults.cmake @@ -0,0 +1,39 @@ +# CMake macOS defaults module + +include_guard(GLOBAL) + +# Set empty codesigning team if not specified as cache variable +if(NOT CODESIGN_TEAM) + set(CODESIGN_TEAM "" CACHE STRING "OBS code signing team for macOS" FORCE) + + # Set ad-hoc codesigning identity if not specified as cache variable + if(NOT CODESIGN_IDENTITY) + set(CODESIGN_IDENTITY "-" CACHE STRING "OBS code signing identity for macOS" FORCE) + endif() +endif() + +include(xcode) + +include(buildspec) + +# Use Applications directory as default install destination +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set( + CMAKE_INSTALL_PREFIX + "$ENV{HOME}/Library/Application Support/obs-studio/plugins" + CACHE STRING + "Default plugin installation directory" + FORCE + ) +endif() + +# Enable find_package targets to become globally available targets +set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL TRUE) +# Enable RPATH support for generated binaries +set(CMAKE_MACOSX_RPATH TRUE) +# Use RPATHs from build tree _in_ the build tree +set(CMAKE_BUILD_WITH_INSTALL_RPATH FALSE) +# Do not add default linker search paths to RPATH +set(CMAKE_INSTALL_RPATH_USE_LINK_PATH FALSE) +# Use common bundle-relative RPATH for installed targets +set(CMAKE_INSTALL_RPATH "@executable_path/../Frameworks") diff --git a/cpp/obs/cmake/macos/helpers.cmake b/cpp/obs/cmake/macos/helpers.cmake new file mode 100644 index 0000000000..ab6c5f3052 --- /dev/null +++ b/cpp/obs/cmake/macos/helpers.cmake @@ -0,0 +1,100 @@ +# CMake macOS helper functions module + +include_guard(GLOBAL) + +include(helpers_common) + +# set_target_properties_obs: Set target properties for use in obs-studio +function(set_target_properties_plugin target) + set(options "") + set(oneValueArgs "") + set(multiValueArgs PROPERTIES) + cmake_parse_arguments(PARSE_ARGV 0 _STPO "${options}" "${oneValueArgs}" "${multiValueArgs}") + + message(DEBUG "Setting additional properties for target ${target}...") + + while(_STPO_PROPERTIES) + list(POP_FRONT _STPO_PROPERTIES key value) + set_property(TARGET ${target} PROPERTY ${key} "${value}") + endwhile() + + string(TIMESTAMP CURRENT_YEAR "%Y") + set_target_properties( + ${target} + PROPERTIES + BUNDLE TRUE + BUNDLE_EXTENSION plugin + XCODE_ATTRIBUTE_PRODUCT_NAME ${target} + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER ${MACOS_BUNDLEID} + XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION ${PLUGIN_BUILD_NUMBER} + XCODE_ATTRIBUTE_MARKETING_VERSION ${PLUGIN_VERSION} + XCODE_ATTRIBUTE_GENERATE_INFOPLIST_FILE YES + XCODE_ATTRIBUTE_INFOPLIST_FILE "" + XCODE_ATTRIBUTE_INFOPLIST_KEY_CFBundleDisplayName ${target} + XCODE_ATTRIBUTE_INFOPLIST_KEY_NSHumanReadableCopyright "(c) ${CURRENT_YEAR} ${PLUGIN_AUTHOR}" + XCODE_ATTRIBUTE_INSTALL_PATH "$(USER_LIBRARY_DIR)/Application Support/obs-studio/plugins" + ) + + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos/entitlements.plist") + set_target_properties( + ${target} + PROPERTIES XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos/entitlements.plist" + ) + endif() + + if(TARGET plugin-support) + target_link_libraries(${target} PRIVATE plugin-support) + endif() + + target_install_resources(${target}) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMAND + "${CMAKE_COMMAND}" -E copy_directory "$" + "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/$" + COMMENT "Copy ${target} to rundir" + VERBATIM + ) + + get_target_property(target_sources ${target} SOURCES) + set(target_ui_files ${target_sources}) + list(FILTER target_ui_files INCLUDE REGEX ".+\\.(ui|qrc)") + source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" PREFIX "UI Files" FILES ${target_ui_files}) + + install(TARGETS ${target} LIBRARY DESTINATION .) + install(FILES "$.dsym" CONFIGURATIONS Release DESTINATION . OPTIONAL) + + configure_file(cmake/macos/resources/distribution.in "${CMAKE_CURRENT_BINARY_DIR}/distribution" @ONLY) + configure_file(cmake/macos/resources/create-package.cmake.in "${CMAKE_CURRENT_BINARY_DIR}/create-package.cmake" @ONLY) + install(SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/create-package.cmake") +endfunction() + +# target_install_resources: Helper function to add resources into bundle +function(target_install_resources target) + message(DEBUG "Installing resources for target ${target}...") + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/data") + file(GLOB_RECURSE data_files "${CMAKE_CURRENT_SOURCE_DIR}/data/*") + foreach(data_file IN LISTS data_files) + cmake_path( + RELATIVE_PATH data_file + BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" + OUTPUT_VARIABLE relative_path + ) + cmake_path(GET relative_path PARENT_PATH relative_path) + target_sources(${target} PRIVATE "${data_file}") + set_property(SOURCE "${data_file}" PROPERTY MACOSX_PACKAGE_LOCATION "Resources/${relative_path}") + source_group("Resources/${relative_path}" FILES "${data_file}") + endforeach() + endif() +endfunction() + +# target_add_resource: Helper function to add a specific resource to a bundle +function(target_add_resource target resource) + message(DEBUG "Add resource ${resource} to target ${target} at destination ${destination}...") + target_sources(${target} PRIVATE "${resource}") + set_property(SOURCE "${resource}" PROPERTY MACOSX_PACKAGE_LOCATION Resources) + source_group("Resources" FILES "${resource}") +endfunction() diff --git a/cpp/obs/cmake/macos/resources/ccache-launcher-c.in b/cpp/obs/cmake/macos/resources/ccache-launcher-c.in new file mode 100644 index 0000000000..b3b9dcfd83 --- /dev/null +++ b/cpp/obs/cmake/macos/resources/ccache-launcher-c.in @@ -0,0 +1,26 @@ +#!/bin/sh + +if [[ "$1" == "${CMAKE_C_COMPILER}" ]] ; then + shift +fi + +export CCACHE_DIR='${CMAKE_SOURCE_DIR}/.ccache' +export CCACHE_MAXSIZE='1G' +export CCACHE_CPP2=true +export CCACHE_DEPEND=true +export CCACHE_DIRECT=true +export CCACHE_FILECLONE=true +export CCACHE_INODECACHE=true +export CCACHE_COMPILERCHECK='content' + +CCACHE_SLOPPINESS='file_stat_matches,include_file_mtime,include_file_ctime,system_headers' + +if [[ "${CMAKE_C_COMPILER_ID}" == "AppleClang" ]]; then + CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS},modules,clang_index_store" +fi +export CCACHE_SLOPPINESS + +if [[ "${CI}" ]]; then + export CCACHE_NOHASHDIR=true +fi +exec "${CMAKE_C_COMPILER_LAUNCHER}" "${CMAKE_C_COMPILER}" "$@" diff --git a/cpp/obs/cmake/macos/resources/ccache-launcher-cxx.in b/cpp/obs/cmake/macos/resources/ccache-launcher-cxx.in new file mode 100644 index 0000000000..030ee2ca23 --- /dev/null +++ b/cpp/obs/cmake/macos/resources/ccache-launcher-cxx.in @@ -0,0 +1,26 @@ +#!/bin/sh + +if [[ "$1" == "${CMAKE_CXX_COMPILER}" ]] ; then + shift +fi + +export CCACHE_DIR='${CMAKE_SOURCE_DIR}/.ccache' +export CCACHE_MAXSIZE='1G' +export CCACHE_CPP2=true +export CCACHE_DEPEND=true +export CCACHE_DIRECT=true +export CCACHE_FILECLONE=true +export CCACHE_INODECACHE=true +export CCACHE_COMPILERCHECK='content' + +CCACHE_SLOPPINESS='file_stat_matches,include_file_mtime,include_file_ctime,system_headers' + +if [[ "${CMAKE_C_COMPILER_ID}" == "AppleClang" ]]; then + CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS},modules,clang_index_store" +fi +export CCACHE_SLOPPINESS + +if [[ "${CI}" ]]; then + export CCACHE_NOHASHDIR=true +fi +exec "${CMAKE_CXX_COMPILER_LAUNCHER}" "${CMAKE_CXX_COMPILER}" "$@" diff --git a/cpp/obs/cmake/macos/resources/create-package.cmake.in b/cpp/obs/cmake/macos/resources/create-package.cmake.in new file mode 100644 index 0000000000..a2f1b112a8 --- /dev/null +++ b/cpp/obs/cmake/macos/resources/create-package.cmake.in @@ -0,0 +1,35 @@ +make_directory("$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package/Library/Application Support/obs-studio/plugins") + +if(EXISTS "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin" AND NOT IS_SYMLINK "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin") + file(INSTALL DESTINATION "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package/Library/Application Support/obs-studio/plugins" + TYPE DIRECTORY FILES "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin" USE_SOURCE_PERMISSIONS) + + if(CMAKE_INSTALL_CONFIG_NAME MATCHES "^([Rr][Ee][Ll][Ee][Aa][Ss][Ee])$" OR CMAKE_INSTALL_CONFIG_NAME MATCHES "^([Mm][Ii][Nn][Ss][Ii][Zz][Ee][Rr][Ee][Ll])$") + if(EXISTS "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin.dSYM" AND NOT IS_SYMLINK "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin.dSYM") + file(INSTALL DESTINATION "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package/Library/Application Support/obs-studio/plugins" TYPE DIRECTORY FILES "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin.dSYM" USE_SOURCE_PERMISSIONS) + endif() + endif() +endif() + +make_directory("$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/temp") + +execute_process( + COMMAND /usr/bin/pkgbuild + --identifier '@MACOS_BUNDLEID@' + --version '@CMAKE_PROJECT_VERSION@' + --root "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package" + "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/temp/@CMAKE_PROJECT_NAME@.pkg" + COMMAND_ERROR_IS_FATAL ANY + ) + +execute_process( + COMMAND /usr/bin/productbuild + --distribution "@CMAKE_CURRENT_BINARY_DIR@/distribution" + --package-path "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/temp" + "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.pkg" + COMMAND_ERROR_IS_FATAL ANY) + +if(EXISTS "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.pkg") + file(REMOVE_RECURSE "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/temp") + file(REMOVE_RECURSE "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package") +endif() diff --git a/cpp/obs/cmake/macos/resources/distribution.in b/cpp/obs/cmake/macos/resources/distribution.in new file mode 100644 index 0000000000..016043b030 --- /dev/null +++ b/cpp/obs/cmake/macos/resources/distribution.in @@ -0,0 +1,33 @@ + + + + + @CMAKE_PROJECT_NAME@ + + + + + + + #@CMAKE_PROJECT_NAME@.pkg + + + diff --git a/cpp/obs/cmake/macos/resources/installer-macos.pkgproj.in b/cpp/obs/cmake/macos/resources/installer-macos.pkgproj.in new file mode 100644 index 0000000000..9a3f89435b --- /dev/null +++ b/cpp/obs/cmake/macos/resources/installer-macos.pkgproj.in @@ -0,0 +1,920 @@ + + + + + PACKAGES + + + MUST-CLOSE-APPLICATION-ITEMS + + MUST-CLOSE-APPLICATIONS + + PACKAGE_FILES + + DEFAULT_INSTALL_LOCATION + / + HIERARCHY + + CHILDREN + + + CHILDREN + + GID + 80 + PATH + Applications + PATH_TYPE + 0 + PERMISSIONS + 509 + TYPE + 1 + UID + 0 + + + CHILDREN + + + CHILDREN + + + CHILDREN + + + CHILDREN + + + BUNDLE_CAN_DOWNGRADE + + BUNDLE_POSTINSTALL_PATH + + PATH_TYPE + 0 + + BUNDLE_PREINSTALL_PATH + + PATH_TYPE + 0 + + CHILDREN + + GID + 80 + PATH + ../release/@CMAKE_INSTALL_CONFIG_NAME@/@CMAKE_PROJECT_NAME@.plugin + PATH_TYPE + 1 + PERMISSIONS + 493 + TYPE + 3 + UID + 0 + + + GID + 80 + PATH + plugins + PATH_TYPE + 2 + PERMISSIONS + 509 + TYPE + 2 + UID + 0 + + + GID + 80 + PATH + obs-studio + PATH_TYPE + 2 + PERMISSIONS + 509 + TYPE + 2 + UID + 0 + + + GID + 80 + PATH + Application Support + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Automator + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Documentation + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Extensions + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Filesystems + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Frameworks + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Input Methods + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Internet Plug-Ins + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + LaunchAgents + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + LaunchDaemons + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + PreferencePanes + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Preferences + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 80 + PATH + Printers + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + PrivilegedHelperTools + PATH_TYPE + 0 + PERMISSIONS + 1005 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + QuickLook + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + QuickTime + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Screen Savers + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Scripts + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Services + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Widgets + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + GID + 0 + PATH + Library + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + + CHILDREN + + GID + 0 + PATH + Shared + PATH_TYPE + 0 + PERMISSIONS + 1023 + TYPE + 1 + UID + 0 + + + GID + 80 + PATH + Users + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + GID + 0 + PATH + / + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + PAYLOAD_TYPE + 0 + PRESERVE_EXTENDED_ATTRIBUTES + + SHOW_INVISIBLE + + SPLIT_FORKS + + TREAT_MISSING_FILES_AS_WARNING + + VERSION + 5 + + PACKAGE_SCRIPTS + + POSTINSTALL_PATH + + PATH_TYPE + 0 + + PREINSTALL_PATH + + PATH_TYPE + 0 + + RESOURCES + + + PACKAGE_SETTINGS + + AUTHENTICATION + 0 + CONCLUSION_ACTION + 0 + FOLLOW_SYMBOLIC_LINKS + + IDENTIFIER + @MACOS_BUNDLEID@ + LOCATION + 0 + NAME + @CMAKE_PROJECT_NAME@ + OVERWRITE_PERMISSIONS + + PAYLOAD_SIZE + -1 + REFERENCE_PATH + + RELOCATABLE + + USE_HFS+_COMPRESSION + + VERSION + @CMAKE_PROJECT_VERSION@ + + TYPE + 0 + UUID + @UUID_PACKAGE@ + + + PROJECT + + PROJECT_COMMENTS + + NOTES + + + + PROJECT_PRESENTATION + + BACKGROUND + + APPAREANCES + + DARK_AQUA + + LIGHT_AQUA + + + SHARED_SETTINGS_FOR_ALL_APPAREANCES + + + INSTALLATION TYPE + + HIERARCHIES + + INSTALLER + + LIST + + + CHILDREN + + DESCRIPTION + + OPTIONS + + HIDDEN + + STATE + 1 + + PACKAGE_UUID + @UUID_PACKAGE@ + TITLE + + TYPE + 0 + UUID + @UUID_INSTALLER@ + + + REMOVED + + + + MODE + 0 + + INSTALLATION_STEPS + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewIntroductionController + INSTALLER_PLUGIN + Introduction + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewReadMeController + INSTALLER_PLUGIN + ReadMe + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewLicenseController + INSTALLER_PLUGIN + License + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewDestinationSelectController + INSTALLER_PLUGIN + TargetSelect + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewInstallationTypeController + INSTALLER_PLUGIN + PackageSelection + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewInstallationController + INSTALLER_PLUGIN + Install + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewSummaryController + INSTALLER_PLUGIN + Summary + LIST_TITLE_KEY + InstallerSectionTitle + + + INTRODUCTION + + LOCALIZATIONS + + + LICENSE + + LOCALIZATIONS + + MODE + 0 + + README + + LOCALIZATIONS + + + SUMMARY + + LOCALIZATIONS + + + TITLE + + LOCALIZATIONS + + + + PROJECT_REQUIREMENTS + + LIST + + + BEHAVIOR + 3 + DICTIONARY + + IC_REQUIREMENT_OS_DISK_TYPE + 1 + IC_REQUIREMENT_OS_DISTRIBUTION_TYPE + 0 + IC_REQUIREMENT_OS_MINIMUM_VERSION + 101300 + + IC_REQUIREMENT_CHECK_TYPE + 0 + IDENTIFIER + fr.whitebox.Packages.requirement.os + MESSAGE + + NAME + Operating System + STATE + + + + RESOURCES + + ROOT_VOLUME_ONLY + + + PROJECT_SETTINGS + + ADVANCED_OPTIONS + + installer-script.domains:enable_currentUserHome + 1 + + BUILD_FORMAT + 0 + BUILD_PATH + + PATH + . + PATH_TYPE + 1 + + EXCLUDED_FILES + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + .DS_Store + TYPE + 0 + + + PROTECTED + + PROXY_NAME + Remove .DS_Store files + PROXY_TOOLTIP + Remove ".DS_Store" files created by the Finder. + STATE + + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + .pbdevelopment + TYPE + 0 + + + PROTECTED + + PROXY_NAME + Remove .pbdevelopment files + PROXY_TOOLTIP + Remove ".pbdevelopment" files created by ProjectBuilder or Xcode. + STATE + + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + CVS + TYPE + 1 + + + REGULAR_EXPRESSION + + STRING + .cvsignore + TYPE + 0 + + + REGULAR_EXPRESSION + + STRING + .cvspass + TYPE + 0 + + + REGULAR_EXPRESSION + + STRING + .svn + TYPE + 1 + + + REGULAR_EXPRESSION + + STRING + .git + TYPE + 1 + + + REGULAR_EXPRESSION + + STRING + .gitignore + TYPE + 0 + + + PROTECTED + + PROXY_NAME + Remove SCM metadata + PROXY_TOOLTIP + Remove helper files and folders used by the CVS, SVN or Git Source Code Management systems. + STATE + + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + classes.nib + TYPE + 0 + + + REGULAR_EXPRESSION + + STRING + designable.db + TYPE + 0 + + + REGULAR_EXPRESSION + + STRING + info.nib + TYPE + 0 + + + PROTECTED + + PROXY_NAME + Optimize nib files + PROXY_TOOLTIP + Remove "classes.nib", "info.nib" and "designable.nib" files within .nib bundles. + STATE + + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + Resources Disabled + TYPE + 1 + + + PROTECTED + + PROXY_NAME + Remove Resources Disabled folders + PROXY_TOOLTIP + Remove "Resources Disabled" folders. + STATE + + + + SEPARATOR + + + + NAME + @CMAKE_PROJECT_NAME@ + PAYLOAD_ONLY + + TREAT_MISSING_PRESENTATION_DOCUMENTS_AS_WARNING + + + + TYPE + 0 + VERSION + 2 + + diff --git a/cpp/obs/cmake/macos/xcode.cmake b/cpp/obs/cmake/macos/xcode.cmake new file mode 100644 index 0000000000..b232dd3ace --- /dev/null +++ b/cpp/obs/cmake/macos/xcode.cmake @@ -0,0 +1,174 @@ +# CMake macOS Xcode module + +include_guard(GLOBAL) + +set(CMAKE_XCODE_GENERATE_SCHEME TRUE) + +# Use a compiler wrapper to enable ccache in Xcode projects +if(ENABLE_CCACHE AND CCACHE_PROGRAM) + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos/resources/ccache-launcher-c.in" ccache-launcher-c) + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos/resources/ccache-launcher-cxx.in" ccache-launcher-cxx) + + execute_process( + COMMAND chmod a+rx "${CMAKE_CURRENT_BINARY_DIR}/ccache-launcher-c" "${CMAKE_CURRENT_BINARY_DIR}/ccache-launcher-cxx" + ) + set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_CURRENT_BINARY_DIR}/ccache-launcher-c") + set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_CURRENT_BINARY_DIR}/ccache-launcher-cxx") + set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_C_COMPILER}") + set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_CXX_COMPILER}") +endif() + +# Set project variables +set(CMAKE_XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION ${PLUGIN_BUILD_NUMBER}) +set(CMAKE_XCODE_ATTRIBUTE_DYLIB_COMPATIBILITY_VERSION 1.0.0) +set(CMAKE_XCODE_ATTRIBUTE_MARKETING_VERSION ${PLUGIN_VERSION}) + +# Set deployment target +set(CMAKE_XCODE_ATTRIBUTE_MACOSX_DEPLOYMENT_TARGET ${CMAKE_OSX_DEPLOYMENT_TARGET}) + +if(NOT CODESIGN_TEAM) + # Switch to manual codesigning if no codesigning team is provided + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_STYLE Manual) + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "${CODESIGN_IDENTITY}") +else() + if(CODESIGN_IDENTITY AND NOT CODESIGN_IDENTITY STREQUAL "-") + # Switch to manual codesigning if a non-adhoc codesigning identity is provided + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_STYLE Manual) + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "${CODESIGN_IDENTITY}") + else() + # Switch to automatic codesigning via valid team ID + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_STYLE Automatic) + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "Apple Development") + endif() + set(CMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${CODESIGN_TEAM}") +endif() + +# Only create a single Xcode project file +set(CMAKE_XCODE_GENERATE_TOP_LEVEL_PROJECT_ONLY TRUE) +# Add all libraries to project link phase (lets Xcode handle linking) +set(CMAKE_XCODE_LINK_BUILD_PHASE_MODE KNOWN_LOCATION) + +# Enable codesigning with secure timestamp when not in Debug configuration (required for Notarization) +set(CMAKE_XCODE_ATTRIBUTE_OTHER_CODE_SIGN_FLAGS[variant=Release] "--timestamp") +set(CMAKE_XCODE_ATTRIBUTE_OTHER_CODE_SIGN_FLAGS[variant=RelWithDebInfo] "--timestamp") +set(CMAKE_XCODE_ATTRIBUTE_OTHER_CODE_SIGN_FLAGS[variant=MinSizeRel] "--timestamp") + +# Enable codesigning with hardened runtime option when not in Debug configuration (required for Notarization) +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME[variant=Release] YES) +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME[variant=RelWithDebInfo] YES) +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME[variant=MinSizeRel] YES) + +# Disable injection of Xcode's base entitlements used for debugging when not in Debug configuration (required for +# Notarization) +set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_INJECT_BASE_ENTITLEMENTS[variant=Release] NO) +set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_INJECT_BASE_ENTITLEMENTS[variant=RelWithDebInfo] NO) +set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_INJECT_BASE_ENTITLEMENTS[variant=MinSizeRel] NO) + +# Use Swift version 5.0 by default +set(CMAKE_XCODE_ATTRIBUTE_SWIFT_VERSION 5.0) + +# Use DWARF with separate dSYM files when in Release or MinSizeRel configuration. +# +# * Currently overruled by CMake's Xcode generator, requires adding '-g' flag to raw compiler command line for desired +# output configuration. Report to KitWare. +# +set(CMAKE_XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT[variant=Debug] dwarf) +set(CMAKE_XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT[variant=RelWithDebInfo] dwarf) +set(CMAKE_XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT[variant=Release] dwarf-with-dsym) +set(CMAKE_XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT[variant=MinSizeRel] dwarf-with-dsym) + +# Make all symbols hidden by default (currently overriden by CMake's compiler flags) +set(CMAKE_XCODE_ATTRIBUTE_GCC_SYMBOLS_PRIVATE_EXTERN YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_INLINES_ARE_PRIVATE_EXTERN YES) + +# Strip unused code +set(CMAKE_XCODE_ATTRIBUTE_DEAD_CODE_STRIPPING YES) + +# Build active architecture only in Debug configuration +set(CMAKE_XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH[variant=Debug] YES) + +# Enable testability in Debug configuration +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_TESTABILITY[variant=Debug] YES) + +# Enable using ARC in ObjC by default +set(CMAKE_XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC YES) +# Enable weak references in manual retain release +set(CMAKE_XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_WEAK YES) +# Disable strict aliasing +set(CMAKE_XCODE_ATTRIBUTE_GCC_STRICT_ALIASING NO) + +# Set C++ language default to c17 +# +# * CMake explicitly sets the version via compiler flag when transitive dependencies require specific compiler feature +# set, resulting in the flag being added twice. Report to KitWare as a feature request for Xcode generator +# * See also: https://gitlab.kitware.com/cmake/cmake/-/issues/17183 +# +# set(CMAKE_XCODE_ATTRIBUTE_GCC_C_LANGUAGE_STANDARD c17) +# +# Set C++ language default to c++17 +# +# * See above. Report to KitWare as a feature request for Xcode generator +# +# set(CMAKE_XCODE_ATTRIBUTE_CLANG_CXX_LANGUAGE_STANDARD c++17) + +# Enable support for module imports in ObjC +set(CMAKE_XCODE_ATTRIBUTE_CLANG_ENABLE_MODULES YES) +# Enable automatic linking of imported modules in ObjC +set(CMAKE_XCODE_ATTRIBUTE_CLANG_MODULES_AUTOLINK YES) +# Enable strict msg_send rules for ObjC +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_STRICT_OBJC_MSGSEND YES) + +# Set default warnings for ObjC and C++ +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING YES_ERROR) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_BOOL_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_COMMA YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_CONSTANT_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_EMPTY_BODY YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_ENUM_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_INFINITE_RECURSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_INT_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_NON_LITERAL_NULL_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_OBJC_LITERAL_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_RANGE_LOOP_ANALYSIS YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_STRICT_PROTOTYPES NO) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION NO) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_SUSPICIOUS_MOVE YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_UNREACHABLE_CODE YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN__DUPLICATE_METHOD_MATCH YES) + +# Set default warnings for C and C++ +set(CMAKE_XCODE_ATTRIBUTE_GCC_NO_COMMON_BLOCKS YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_64_TO_32_BIT_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS NO) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_ABOUT_MISSING_NEWLINE YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_ABOUT_RETURN_TYPE YES_ERROR) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_CHECK_SWITCH_STATEMENTS YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_FOUR_CHARACTER_CONSTANTS YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_SHADOW NO) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_SIGN_COMPARE YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_TYPECHECK_CALLS_TO_PRINTF YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNDECLARED_SELECTOR YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNINITIALIZED_AUTOS YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNUSED_FUNCTION NO) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNUSED_PARAMETER YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNUSED_VALUE YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNUSED_VARIABLE YES) + +# Add additional warning compiler flags +set(CMAKE_XCODE_ATTRIBUTE_WARNING_CFLAGS "-Wvla -Wformat-security") + +if(CMAKE_COMPILE_WARNING_AS_ERROR) + set(CMAKE_XCODE_ATTRIBUTE_GCC_TREAT_WARNINGS_AS_ERRORS YES) +endif() + +# Enable color diagnostics +set(CMAKE_COLOR_DIAGNOSTICS TRUE) + +# Disable usage of RPATH in build or install configurations +set(CMAKE_SKIP_RPATH TRUE) +# Have Xcode set default RPATH entries +set(CMAKE_XCODE_ATTRIBUTE_LD_RUNPATH_SEARCH_PATHS "@executable_path/../Frameworks") diff --git a/cpp/obs/cmake/windows/buildspec.cmake b/cpp/obs/cmake/windows/buildspec.cmake new file mode 100644 index 0000000000..e0e5ee094f --- /dev/null +++ b/cpp/obs/cmake/windows/buildspec.cmake @@ -0,0 +1,24 @@ +# CMake Windows build dependencies module + +include_guard(GLOBAL) + +include(buildspec_common) + +# _check_dependencies_windows: Set up Windows slice for _check_dependencies +function(_check_dependencies_windows) + set(arch ${CMAKE_VS_PLATFORM_NAME}) + set(platform windows-${arch}) + + set(dependencies_dir "${CMAKE_CURRENT_SOURCE_DIR}/.deps") + set(prebuilt_filename "windows-deps-VERSION-ARCH-REVISION.zip") + set(prebuilt_destination "obs-deps-VERSION-ARCH") + set(qt6_filename "windows-deps-qt6-VERSION-ARCH-REVISION.zip") + set(qt6_destination "obs-deps-qt6-VERSION-ARCH") + set(obs-studio_filename "VERSION.zip") + set(obs-studio_destination "obs-studio-VERSION") + set(dependencies_list prebuilt qt6 obs-studio) + + _check_dependencies() +endfunction() + +_check_dependencies_windows() diff --git a/cpp/obs/cmake/windows/compilerconfig.cmake b/cpp/obs/cmake/windows/compilerconfig.cmake new file mode 100644 index 0000000000..93971a8f7f --- /dev/null +++ b/cpp/obs/cmake/windows/compilerconfig.cmake @@ -0,0 +1,63 @@ +# CMake Windows compiler configuration module + +include_guard(GLOBAL) + +include(compiler_common) + +set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT ProgramDatabase) + +message(DEBUG "Current Windows API version: ${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION}") +if(CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION_MAXIMUM) + message(DEBUG "Maximum Windows API version: ${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION_MAXIMUM}") +endif() + +if(CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION VERSION_LESS 10.0.20348) + message( + FATAL_ERROR + "OBS requires Windows 10 SDK version 10.0.20348.0 or more recent.\n" + "Please download and install the most recent Windows platform SDK." + ) +endif() + +set(_obs_msvc_c_options /MP /Zc:__cplusplus /Zc:preprocessor) +set(_obs_msvc_cpp_options /MP /Zc:__cplusplus /Zc:preprocessor) + +if(CMAKE_CXX_STANDARD GREATER_EQUAL 20) + list(APPEND _obs_msvc_cpp_options /Zc:char8_t-) +endif() + +add_compile_options( + /W3 + /utf-8 + /Brepro + /permissive- + "$<$:${_obs_msvc_c_options}>" + "$<$:${_obs_msvc_cpp_options}>" + "$<$:${_obs_clang_c_options}>" + "$<$:${_obs_clang_cxx_options}>" + $<$>:/Gy> + $<$>:/GL> + $<$>:/Oi> +) + +add_compile_definitions( + UNICODE + _UNICODE + _CRT_SECURE_NO_WARNINGS + _CRT_NONSTDC_NO_WARNINGS + $<$:DEBUG> + $<$:_DEBUG> +) + +add_link_options( + $<$>:/OPT:REF> + $<$>:/OPT:ICF> + $<$>:/LTCG> + $<$>:/INCREMENTAL:NO> + /DEBUG + /Brepro +) + +if(CMAKE_COMPILE_WARNING_AS_ERROR) + add_link_options(/WX) +endif() diff --git a/cpp/obs/cmake/windows/defaults.cmake b/cpp/obs/cmake/windows/defaults.cmake new file mode 100644 index 0000000000..bea3ef3501 --- /dev/null +++ b/cpp/obs/cmake/windows/defaults.cmake @@ -0,0 +1,19 @@ +# CMake Windows defaults module + +include_guard(GLOBAL) + +# Enable find_package targets to become globally available targets +set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL TRUE) + +include(buildspec) + +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + cmake_path(SET ALLUSERSPROFILE_PATH $ENV{ALLUSERSPROFILE}) + set( + CMAKE_INSTALL_PREFIX + "${ALLUSERSPROFILE_PATH}/obs-studio/plugins" + CACHE STRING + "Default plugin installation directory" + FORCE + ) +endif() diff --git a/cpp/obs/cmake/windows/helpers.cmake b/cpp/obs/cmake/windows/helpers.cmake new file mode 100644 index 0000000000..abc2ce4606 --- /dev/null +++ b/cpp/obs/cmake/windows/helpers.cmake @@ -0,0 +1,107 @@ +# CMake Windows helper functions module + +include_guard(GLOBAL) + +include(helpers_common) + +# set_target_properties_plugin: Set target properties for use in obs-studio +function(set_target_properties_plugin target) + set(options "") + set(oneValueArgs "") + set(multiValueArgs PROPERTIES) + cmake_parse_arguments(PARSE_ARGV 0 _STPO "${options}" "${oneValueArgs}" "${multiValueArgs}") + + message(DEBUG "Setting additional properties for target ${target}...") + + while(_STPO_PROPERTIES) + list(POP_FRONT _STPO_PROPERTIES key value) + set_property(TARGET ${target} PROPERTY ${key} "${value}") + endwhile() + + string(TIMESTAMP CURRENT_YEAR "%Y") + + set_target_properties(${target} PROPERTIES VERSION 0 SOVERSION ${PLUGIN_VERSION}) + + install(TARGETS ${target} RUNTIME DESTINATION "${target}/bin/64bit" LIBRARY DESTINATION "${target}/bin/64bit") + + install( + FILES "$" + CONFIGURATIONS RelWithDebInfo Debug Release + DESTINATION "${target}/bin/64bit" + OPTIONAL + ) + + if(TARGET plugin-support) + target_link_libraries(${target} PRIVATE plugin-support) + endif() + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different "$" + "$<$:$>" + "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMENT "Copy ${target} to rundir" + VERBATIM + ) + + target_install_resources(${target}) + + get_target_property(target_sources ${target} SOURCES) + set(target_ui_files ${target_sources}) + list(FILTER target_ui_files INCLUDE REGEX ".+\\.(ui|qrc)") + source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" PREFIX "UI Files" FILES ${target_ui_files}) + + configure_file(cmake/windows/resources/resource.rc.in "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_PROJECT_NAME}.rc") + target_sources(${CMAKE_PROJECT_NAME} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_PROJECT_NAME}.rc") +endfunction() + +# Helper function to add resources into bundle +function(target_install_resources target) + message(DEBUG "Installing resources for target ${target}...") + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/data") + file(GLOB_RECURSE data_files "${CMAKE_CURRENT_SOURCE_DIR}/data/*") + foreach(data_file IN LISTS data_files) + cmake_path( + RELATIVE_PATH data_file + BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" + OUTPUT_VARIABLE relative_path + ) + cmake_path(GET relative_path PARENT_PATH relative_path) + target_sources(${target} PRIVATE "${data_file}") + source_group("Resources/${relative_path}" FILES "${data_file}") + endforeach() + + install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" DESTINATION "${target}/data" USE_SOURCE_PERMISSIONS) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMAND + "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/data" + "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMENT "Copy ${target} resources to rundir" + VERBATIM + ) + endif() +endfunction() + +# Helper function to add a specific resource to a bundle +function(target_add_resource target resource) + message(DEBUG "Add resource '${resource}' to target ${target} at destination '${target_destination}'...") + + install(FILES "${resource}" DESTINATION "${target}/data" COMPONENT Runtime) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMAND "${CMAKE_COMMAND}" -E copy "${resource}" "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMENT "Copy ${target} resource ${resource} to rundir" + VERBATIM + ) + source_group("Resources" FILES "${resource}") +endfunction() diff --git a/cpp/obs/cmake/windows/resources/resource.rc.in b/cpp/obs/cmake/windows/resources/resource.rc.in new file mode 100644 index 0000000000..5f3b00ec28 --- /dev/null +++ b/cpp/obs/cmake/windows/resources/resource.rc.in @@ -0,0 +1,32 @@ +1 VERSIONINFO + FILEVERSION ${PROJECT_VERSION_MAJOR},${PROJECT_VERSION_MINOR},${PROJECT_VERSION_PATCH},0 + PRODUCTVERSION ${PROJECT_VERSION_MAJOR},${PROJECT_VERSION_MINOR},${PROJECT_VERSION_PATCH},0 + FILEFLAGSMASK 0x0L +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x0L + FILETYPE 0x2L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "${PLUGIN_AUTHOR}" + VALUE "FileDescription", "${PROJECT_NAME}" + VALUE "FileVersion", "${PROJECT_VERSION}" + VALUE "InternalName", "${PROJECT_NAME}" + VALUE "LegalCopyright", "(C) ${CURRENT_YEAR} ${PLUGIN_AUTHOR}" + VALUE "OriginalFilename", "${PROJECT_NAME}" + VALUE "ProductName", "${PROJECT_NAME}" + VALUE "ProductVersion", "${PROJECT_VERSION}" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/cpp/obs/data/locale/en-US.ini b/cpp/obs/data/locale/en-US.ini new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cpp/obs/justfile b/cpp/obs/justfile new file mode 100644 index 0000000000..1abe47d854 --- /dev/null +++ b/cpp/obs/justfile @@ -0,0 +1,96 @@ +#!/usr/bin/env just --justfile + +# OBS Studio plugin for MoQ. See doc/bin/obs.md for the full build guide. +# +# Linux: `nix develop` provides obs-studio/qt6/ffmpeg/cmake, then `just obs build`. +# macOS: native, NOT nix. Needs full Xcode; run outside `nix develop` (its +# toolchain breaks the Xcode build). `just obs setup` downloads +# libobs/Qt6/ffmpeg via buildspec.json (obs-deps). +# Windows: needs Visual Studio 2022; same buildspec download (run from Git Bash). +# +# libmoq is built from the in-tree crate (rs/libmoq) via cargo. MOQ_LOCAL points +# CMake at the repo root by default, so there's no prebuilt release to download. + +set quiet + +# List all of the available commands. +default: + just --list + +# Configure via CMake presets. MOQ_LOCAL defaults to the repo root inside +# CMakeLists.txt (so libmoq builds from rs/libmoq); pass `path=/some/moq` to +# override. We deliberately don't compute the path here: `justfile_directory()` +# resolves to the root justfile when this runs as `just obs setup`, so the +# relative math would point outside the repo. +setup preset="" path="": + #!/usr/bin/env bash + set -euo pipefail + PRESET=$(just preset "{{ preset }}") + if [ -n "{{ path }}" ]; then + echo "Configuring with preset: $PRESET and MOQ_LOCAL={{ path }}" + cmake --preset "$PRESET" -DMOQ_LOCAL="{{ path }}" + else + echo "Configuring with preset: $PRESET (MOQ_LOCAL defaults to the repo root)" + cmake --preset "$PRESET" + fi + +# Build via CMake presets. Run `just obs setup` first (it configures + downloads +# deps); not chained here because on macOS setup reconfigures OBS, which is slow. +build preset="": + #!/usr/bin/env bash + set -euo pipefail + PRESET=$(just preset "{{ preset }}") + cmake --build --preset "$PRESET" + +# Copy the freshly built plugin into the OBS user plugin dir and launch OBS. +# Uses the installed OBS (no local OBS build required). macOS only for now. +run: + #!/usr/bin/env bash + set -euo pipefail + if [[ "$OSTYPE" != "darwin"* ]]; then + echo "just run is macOS-only for now; copy build_*/obs-moq.* into your OBS plugin dir manually" >&2 + exit 1 + fi + dest="$HOME/Library/Application Support/obs-studio/plugins" + mkdir -p "$dest" + cp -a build_macos/RelWithDebInfo/obs-moq.plugin "$dest/" + RUST_LOG=debug RUST_BACKTRACE=1 OBS_LOG_LEVEL=debug /Applications/OBS.app/Contents/MacOS/OBS + +# Lint formatting. Skips a tool silently if it isn't on $PATH (matches repo convention). +check: + #!/usr/bin/env bash + set -euo pipefail + if command -v clang-format >/dev/null; then + git ls-files 'src/*.cpp' 'src/*.h' | xargs clang-format --dry-run --Werror + fi + if command -v gersemi >/dev/null; then + gersemi --check CMakeLists.txt cmake + fi + +# Auto-fix formatting. +fix: + #!/usr/bin/env bash + set -euo pipefail + if command -v clang-format >/dev/null; then + git ls-files 'src/*.cpp' 'src/*.h' | xargs clang-format -i + fi + if command -v gersemi >/dev/null; then + gersemi --in-place CMakeLists.txt cmake + fi + +# Detect the CMake preset for the current platform (or use the override). +preset override="": + #!/usr/bin/env bash + set -euo pipefail + if [[ -n "{{ override }}" ]]; then + echo "{{ override }}" + elif [[ "$OSTYPE" == "darwin"* ]]; then + echo "macos" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + echo "ubuntu-x86_64" + elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + echo "windows-x64" + else + echo "Unknown platform: $OSTYPE" >&2 + exit 1 + fi diff --git a/cpp/obs/src/logger.h b/cpp/obs/src/logger.h new file mode 100644 index 0000000000..d34c9ecc22 --- /dev/null +++ b/cpp/obs/src/logger.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +// Logging macros - use MOQ_ prefix to avoid conflicts with OBS log level constants +#define MOQ_LOG(level, format, ...) blog(level, "[obs-moq] " format, ##__VA_ARGS__) +#define LOG_DEBUG(format, ...) MOQ_LOG(400, format, ##__VA_ARGS__) +#define LOG_INFO(format, ...) MOQ_LOG(300, format, ##__VA_ARGS__) +#define LOG_WARNING(format, ...) MOQ_LOG(200, format, ##__VA_ARGS__) +#define LOG_ERROR(format, ...) MOQ_LOG(100, format, ##__VA_ARGS__) diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp new file mode 100644 index 0000000000..07357d1f4b --- /dev/null +++ b/cpp/obs/src/moq-dock.cpp @@ -0,0 +1,402 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "moq-dock.h" +#include "logger.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#ifndef MOQ_VERSION_STRING +#define MOQ_VERSION_STRING "unknown" +#endif + +namespace { + +// Map OBS's "simple output" encoder aliases to real encoder ids, mirroring the +// table OBS uses internally. Falls back to x264 for anything unrecognized. +const char *SimpleVideoEncoderId(const char *name) +{ + if (!name) + return "obs_x264"; + if (strcmp(name, "x264") == 0 || strcmp(name, "x264_lowcpu") == 0) + return "obs_x264"; + if (strcmp(name, "qsv") == 0) + return "obs_qsv11_v2"; + if (strcmp(name, "qsv_av1") == 0) + return "obs_qsv11_av1_v2"; + if (strcmp(name, "amd") == 0) + return "h264_texture_amf"; + if (strcmp(name, "amd_hevc") == 0) + return "h265_texture_amf"; + if (strcmp(name, "amd_av1") == 0) + return "av1_texture_amf"; + if (strcmp(name, "nvenc") == 0) + return "obs_nvenc_h264_tex"; + if (strcmp(name, "nvenc_hevc") == 0) + return "obs_nvenc_hevc_tex"; + if (strcmp(name, "nvenc_av1") == 0) + return "obs_nvenc_av1_tex"; + if (strcmp(name, "apple_h264") == 0) + return "com.apple.videotoolbox.videoencoder.ave.avc"; + if (strcmp(name, "apple_hevc") == 0) + return "com.apple.videotoolbox.videoencoder.ave.hevc"; + return "obs_x264"; +} + +const char *SimpleAudioEncoderId(const char *name) +{ + if (name && strcmp(name, "opus") == 0) + return "ffmpeg_opus"; + return "ffmpeg_aac"; +} + +std::string SettingsPath() +{ + char *p = obs_module_config_path("dock.json"); + std::string s = p ? p : ""; + bfree(p); + return s; +} + +// Default broadcast name "obs-" so distinct setups don't collide on a +// shared relay out of the box. Only used until the user edits/saves their own. +std::string RandomBroadcastName() +{ + static const char charset[] = "abcdefghijklmnopqrstuvwxyz0123456789"; + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution dist(0, (int)sizeof(charset) - 2); + std::string s = "obs-"; + for (int i = 0; i < 6; i++) + s += charset[dist(gen)]; + return s; +} + +} // namespace + +MoQDock::MoQDock(QWidget *parent) : QWidget(parent) +{ + urlEdit = new QLineEdit(this); + urlEdit->setText("https://cdn.moq.dev/anon"); + urlEdit->setPlaceholderText("http://localhost:4443/anon"); + + pathEdit = new QLineEdit(this); + pathEdit->setText(QString::fromStdString(RandomBroadcastName())); + pathEdit->setPlaceholderText("(optional) broadcast name"); + + // Labels above the fields (WrapAllRows), and let the fields grow to the full + // dock width (the macOS default keeps them at their size hint otherwise). + auto *form = new QFormLayout(); + form->setRowWrapPolicy(QFormLayout::WrapAllRows); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + form->setContentsMargins(0, 0, 0, 0); + form->addRow("Relay URL", urlEdit); + form->addRow("Broadcast name", pathEdit); + + button = new QPushButton("Go Live", this); + button->setCursor(Qt::PointingHandCursor); + connect(button, &QPushButton::clicked, this, &MoQDock::ToggleStream); + + status = new QLabel(this); + status->setWordWrap(true); + QFont statusFont = status->font(); + statusFont.setBold(true); + status->setFont(statusFont); + + auto *versionLabel = new QLabel(QString("libmoq %1").arg(MOQ_VERSION_STRING), this); + versionLabel->setAlignment(Qt::AlignRight | Qt::AlignBottom); + versionLabel->setStyleSheet("color: #888888; font-size: 10px;"); + + auto *layout = new QVBoxLayout(this); + layout->setSpacing(10); + layout->addLayout(form); + layout->addWidget(button); + layout->addWidget(status); + layout->addStretch(); + layout->addWidget(versionLabel); + + pollTimer = new QTimer(this); + pollTimer->setInterval(1000); + connect(pollTimer, &QTimer::timeout, this, &MoQDock::UpdateStatus); + + connect(urlEdit, &QLineEdit::editingFinished, this, &MoQDock::SaveSettings); + connect(pathEdit, &QLineEdit::editingFinished, this, &MoQDock::SaveSettings); + + LoadSettings(); + SetRunning(false); +} + +MoQDock::~MoQDock() +{ + StopStream(); +} + +void MoQDock::ToggleStream() +{ + if (running) { + StopStream(); + } else { + StartStream(); + } +} + +bool MoQDock::CreateConfiguredEncoders() +{ + config_t *config = obs_frontend_get_profile_config(); + if (!config) { + LOG_ERROR("No profile config available"); + return false; + } + + const char *mode = config_get_string(config, "Output", "Mode"); + const bool advanced = mode && strcmp(mode, "Advanced") == 0; + + OBSDataAutoRelease videoSettings = obs_data_create(); + OBSDataAutoRelease audioSettings = obs_data_create(); + const char *videoId = nullptr; + const char *audioId = nullptr; + int audioBitrate = 0; + size_t audioMixerIdx = 0; + + if (advanced) { + videoId = config_get_string(config, "AdvOut", "Encoder"); + + // Advanced video encoder settings live in a JSON file in the profile dir. + char *profilePath = obs_frontend_get_current_profile_path(); + if (profilePath) { + std::string file = std::string(profilePath) + "/streamEncoder.json"; + bfree(profilePath); + OBSDataAutoRelease loaded = obs_data_create_from_json_file(file.c_str()); + if (loaded) + obs_data_apply(videoSettings, loaded); + } + + audioId = config_get_string(config, "AdvOut", "AudioEncoder"); + int track = (int)config_get_int(config, "AdvOut", "TrackIndex"); + if (track < 1) + track = 1; + // OBS config tracks are 1-based; libobs mixer indices are 0-based. + audioMixerIdx = (size_t)(track - 1); + char key[32]; + snprintf(key, sizeof(key), "Track%dBitrate", track); + audioBitrate = (int)config_get_int(config, "AdvOut", key); + } else { + videoId = SimpleVideoEncoderId(config_get_string(config, "SimpleOutput", "StreamEncoder")); + int videoBitrate = (int)config_get_int(config, "SimpleOutput", "VBitrate"); + if (videoBitrate <= 0) + videoBitrate = 2500; + obs_data_set_int(videoSettings, "bitrate", videoBitrate); + obs_data_set_string(videoSettings, "rate_control", "CBR"); + const char *preset = config_get_string(config, "SimpleOutput", "Preset"); + if (preset) + obs_data_set_string(videoSettings, "preset", preset); + + audioId = SimpleAudioEncoderId(config_get_string(config, "SimpleOutput", "StreamAudioEncoder")); + audioBitrate = (int)config_get_int(config, "SimpleOutput", "ABitrate"); + } + + if (!videoId || !*videoId) + videoId = "obs_x264"; + if (!audioId || !*audioId) + audioId = "ffmpeg_aac"; + if (audioBitrate <= 0) + audioBitrate = 160; + + // MoQ publishes inline headers (avc3/hev1), so force repeat_headers and no + // B-frames, mirroring MoQService::ApplyEncoderSettings. + obs_data_set_bool(videoSettings, "repeat_headers", true); + obs_data_set_int(videoSettings, "bf", 0); + obs_data_set_int(audioSettings, "bitrate", audioBitrate); + + videoEncoder = + OBSEncoderAutoRelease(obs_video_encoder_create(videoId, "moq_dock_video", videoSettings, nullptr)); + audioEncoder = OBSEncoderAutoRelease( + obs_audio_encoder_create(audioId, "moq_dock_audio", audioSettings, audioMixerIdx, nullptr)); + if (!videoEncoder || !audioEncoder) { + LOG_ERROR("Failed to create encoders (%s / %s)", videoId, audioId); + return false; + } + + obs_encoder_set_video(videoEncoder, obs_get_video()); + obs_encoder_set_audio(audioEncoder, obs_get_audio()); + + LOG_INFO("Using configured stream encoders: %s / %s", videoId, audioId); + return true; +} + +void MoQDock::StartStream() +{ + const std::string url = urlEdit->text().toStdString(); + const std::string path = pathEdit->text().toStdString(); + if (url.empty()) { + status->setText("Relay URL is required"); + return; + } + + SaveSettings(); + + // The MoQ output reads the server URL / path from its attached service, so + // build a throwaway service from the dock fields. + OBSDataAutoRelease serviceSettings = obs_data_create(); + obs_data_set_string(serviceSettings, "server", url.c_str()); + obs_data_set_string(serviceSettings, "key", path.c_str()); + service = + OBSServiceAutoRelease(obs_service_create("moq_service", "moq_dock_service", serviceSettings, nullptr)); + if (!service) { + status->setText("Failed to create service"); + return; + } + + if (!CreateConfiguredEncoders()) { + status->setText("Failed to set up encoders"); + return; + } + + output = OBSOutputAutoRelease(obs_output_create("moq_output", "moq_dock_output", nullptr, nullptr)); + if (!output) { + status->setText("Failed to create output"); + return; + } + + obs_output_set_service(output, service); + obs_output_set_video_encoder(output, videoEncoder); + obs_output_set_audio_encoder(output, audioEncoder, 0); + + signal_handler_connect(obs_output_get_signal_handler(output), "stop", OnOutputStopped, this); + + if (!obs_output_start(output)) { + const char *err = obs_output_get_last_error(output); + status->setText(err ? QString("Failed to start: %1").arg(err) : "Failed to start"); + LOG_ERROR("Failed to start MoQ dock output: %s", err ? err : "(no error)"); + StopStream(); + return; + } + + pollTimer->start(); + + SetRunning(true); + status->setText("● Connecting…"); + status->setStyleSheet("color: #d08b1d;"); +} + +void MoQDock::StopStream() +{ + pollTimer->stop(); + + if (output) { + signal_handler_disconnect(obs_output_get_signal_handler(output), "stop", OnOutputStopped, this); + obs_output_stop(output); + } + + output = nullptr; + service = nullptr; + videoEncoder = nullptr; + audioEncoder = nullptr; + + SetRunning(false); +} + +void MoQDock::SetRunning(bool isRunning) +{ + running = isRunning; + + button->setText(isRunning ? "Stop" : "Go Live"); + button->setStyleSheet(QString("QPushButton { padding: 8px; border-radius: 4px; font-weight: bold; " + "color: white; background-color: %1; }" + "QPushButton:hover { background-color: %2; }") + .arg(isRunning ? "#c0392b" : "#2d8a4e") + .arg(isRunning ? "#e04434" : "#36a45e")); + + urlEdit->setEnabled(!isRunning); + pathEdit->setEnabled(!isRunning); + + if (!isRunning) { + status->setText("● Disconnected"); + status->setStyleSheet("color: #888888;"); + } +} + +void MoQDock::UpdateStatus() +{ + if (!output || !running) + return; + + // libmoq surfaces connection state via the session-connect callback, which + // MoQOutput records as the output's connect time; until that fires we're + // still connecting. There's no per-frame stats API to show beyond this. + const bool connected = obs_output_get_connect_time_ms(output) > 0; + status->setText(connected ? "● Connected" : "● Connecting…"); + status->setStyleSheet(connected ? "color: #36a45e;" : "color: #d08b1d;"); +} + +void MoQDock::LoadSettings() +{ + const std::string path = SettingsPath(); + if (path.empty()) + return; + + OBSDataAutoRelease data = obs_data_create_from_json_file(path.c_str()); + if (!data) + return; + + const char *url = obs_data_get_string(data, "url"); + const char *broadcast = obs_data_get_string(data, "path"); + if (url && *url) + urlEdit->setText(url); + if (obs_data_has_user_value(data, "path")) + pathEdit->setText(broadcast ? broadcast : ""); +} + +void MoQDock::SaveSettings() +{ + const std::string path = SettingsPath(); + if (path.empty()) + return; + + QDir().mkpath(QFileInfo(QString::fromStdString(path)).absolutePath()); + + OBSDataAutoRelease data = obs_data_create(); + obs_data_set_string(data, "url", urlEdit->text().toUtf8().constData()); + obs_data_set_string(data, "path", pathEdit->text().toUtf8().constData()); + obs_data_save_json(data, path.c_str()); +} + +void MoQDock::OnOutputStopped(void *data, calldata_t *params) +{ + auto *self = static_cast(data); + long long code = calldata_int(params, "code"); + + // Signals arrive on an OBS thread; bounce to the Qt thread before touching widgets. + QMetaObject::invokeMethod( + self, + [self, code]() { + // StopStream() resets the status to "Idle", so set the failure + // message afterwards or it would be immediately overwritten. + self->StopStream(); + if (code != OBS_OUTPUT_SUCCESS) + self->status->setText(QString("Stopped (code %1)").arg(code)); + }, + Qt::QueuedConnection); +} + +void register_moq_dock() +{ + // OBS takes ownership of the widget; create it without a parent. + auto *dock = new MoQDock(); + obs_frontend_add_dock_by_id("moq_dock", "MoQ", dock); +} diff --git a/cpp/obs/src/moq-dock.h b/cpp/obs/src/moq-dock.h new file mode 100644 index 0000000000..aef15d8708 --- /dev/null +++ b/cpp/obs/src/moq-dock.h @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include + +class QLineEdit; +class QPushButton; +class QLabel; +class QTimer; + +// A dockable panel that drives the MoQ output directly, without relying on the +// core Settings -> Stream UI (which does not surface third-party services on +// stable OBS yet). The dock owns its own service/output/encoder objects and +// reuses the encoder settings configured in OBS's Output settings. +class MoQDock : public QWidget { + Q_OBJECT + +public: + explicit MoQDock(QWidget *parent = nullptr); + ~MoQDock() override; + +private slots: + void ToggleStream(); + void UpdateStatus(); + +private: + void StartStream(); + void StopStream(); + void SetRunning(bool running); + bool CreateConfiguredEncoders(); + + void LoadSettings(); + void SaveSettings(); + + // Output "stop" signal handler. Fires on a non-UI thread, so it marshals + // back to the Qt thread before touching widgets. + static void OnOutputStopped(void *data, calldata_t *params); + + QLineEdit *urlEdit; + QLineEdit *pathEdit; + QPushButton *button; + QLabel *status; + + QTimer *pollTimer; + + OBSServiceAutoRelease service; + OBSOutputAutoRelease output; + OBSEncoderAutoRelease videoEncoder; + OBSEncoderAutoRelease audioEncoder; + + bool running = false; +}; + +void register_moq_dock(); diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp new file mode 100644 index 0000000000..75be4ebf3f --- /dev/null +++ b/cpp/obs/src/moq-output.cpp @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include "moq-output.h" +#include "util/util_uint64.h" + +extern "C" { +#include "moq.h" +} + +MoQOutput::MoQOutput(obs_data_t *, obs_output_t *output) + : output(output), + server_url(), + path(), + total_bytes_sent(0), + connect_time_ms(0), + origin(moq_origin_create()), + session(0), + broadcast(moq_publish_create()), + outstanding_sessions(0) +{ +} + +MoQOutput::~MoQOutput() +{ + moq_publish_close(broadcast); + moq_origin_close(origin); + + Stop(); + + // Wait for any outstanding session terminal callback to fire before `this` + // is freed, so a late callback on the libmoq runtime thread can't touch freed + // memory. Bounded so a missing terminal degrades to a warning, not a hang. + std::unique_lock lock(session_mutex); + if (!session_cv.wait_for(lock, std::chrono::seconds(2), [this] { return outstanding_sessions == 0; })) + LOG_WARNING("Output teardown timed out with %d MoQ session callback(s) outstanding", + outstanding_sessions); +} + +bool MoQOutput::Start() +{ + obs_service_t *service = obs_output_get_service(output); + if (!service) { + LOG_ERROR("Failed to get service from output"); + obs_output_signal_stop(output, OBS_OUTPUT_ERROR); + return false; + } + + if (!obs_output_can_begin_data_capture(output, 0)) { + LOG_ERROR("Cannot begin data capture"); + return false; + } + + if (!obs_output_initialize_encoders(output, 0)) { + LOG_ERROR("Failed to initialize encoders"); + return false; + } + + const char *server_value = obs_service_get_connect_info(service, OBS_SERVICE_CONNECT_INFO_SERVER_URL); + server_url = server_value ? server_value : ""; + if (server_url.empty()) { + LOG_ERROR("Server URL is empty"); + obs_output_signal_stop(output, OBS_OUTPUT_BAD_PATH); + return false; + } + + // Path (broadcast name) is optional; an empty string publishes to the unnamed broadcast. + const char *path_value = obs_service_get_connect_info(service, OBS_SERVICE_CONNECT_INFO_STREAM_KEY); + path = path_value ? path_value : ""; + + bool found_encoder = false; + for (uint32_t idx = 0; idx < MAX_OUTPUT_VIDEO_ENCODERS; idx++) { + if (obs_output_get_video_encoder2(output, idx)) { + found_encoder = true; + break; + } + } + + if (!found_encoder) { + LOG_ERROR("Failed to get video encoder"); + return false; + } + + LOG_INFO("Connecting to MoQ server: %s", server_url.c_str()); + + connect_start = std::chrono::steady_clock::now(); + + // Create a callback to log when the session is connected or closed. + // libmoq status codes (>= 0.3.0): > 0 = (re)connected (epoch), 0 = closed + // cleanly (terminal), < 0 = fatal/reconnect-gave-up (terminal). + auto session_connect_callback = [](void *user_data, int code) { + auto self = static_cast(user_data); + + if (code > 0) { + auto elapsed = std::chrono::steady_clock::now() - self->connect_start; + self->connect_time_ms = static_cast( + std::chrono::duration_cast(elapsed).count()); + LOG_INFO("MoQ session connected (%d ms, epoch %d): %s", self->connect_time_ms.load(), code, + self->server_url.c_str()); + } else { + // Terminal callback (0 = clean close, < 0 = fatal): the session task + // has ended and will not touch `self` again. Release the lifetime + // reference the destructor waits on. This is the last access to `self`. + LOG_INFO("MoQ session closed (%d): %s", code, self->server_url.c_str()); + std::lock_guard lock(self->session_mutex); + if (--self->outstanding_sessions == 0) + self->session_cv.notify_all(); + } + }; + + // Pre-account for the session subscription before handing `this` to libmoq: + // the connection can fail and fire its terminal callback before connect + // returns, and the destructor must wait for that callback. + { + std::lock_guard lock(session_mutex); + outstanding_sessions++; + } + + // Start establishing a session with the MoQ server + // NOTE: You could publish the same broadcasts to multiple sessions if you want (redundant ingest). + session = moq_session_connect(server_url.data(), server_url.size(), origin, 0, session_connect_callback, this); + if (session < 0) { + LOG_ERROR("Failed to initialize MoQ server: %d", session); + // No subscription was created, so no terminal will fire; undo the ref. + std::lock_guard lock(session_mutex); + if (--outstanding_sessions == 0) + session_cv.notify_all(); + return false; + } + + LOG_INFO("Publishing broadcast: %s", path.c_str()); + + // Publish the broadcast to the origin we created. + // TODO: There is currently no unpublish function. + auto result = moq_origin_publish(origin, path.data(), path.size(), broadcast); + if (result < 0) { + LOG_ERROR("Failed to publish broadcast to session: %d", result); + // The session connected above; close it so a retry on this same output + // doesn't reuse the stale handle. Its terminal callback releases the + // outstanding-session reference the destructor waits on. + Stop(false); + return false; + } + + obs_output_begin_data_capture(output, 0); + + return true; +} + +void MoQOutput::Stop(bool signal) +{ + // Close the session + if (session > 0) { + moq_session_close(session); + session = 0; + } + + for (auto &[encoder, handle] : video_tracks) { + if (handle > 0) + moq_publish_media_close(handle); + } + video_tracks.clear(); + + for (auto &[encoder, handle] : audio_tracks) { + if (handle > 0) + moq_publish_media_close(handle); + } + audio_tracks.clear(); + + if (signal) { + obs_output_signal_stop(output, OBS_OUTPUT_SUCCESS); + } + + return; +} + +void MoQOutput::Data(struct encoder_packet *packet) +{ + if (!packet) { + Stop(false); + obs_output_signal_stop(output, OBS_OUTPUT_ENCODE_ERROR); + return; + } + + if (packet->type == OBS_ENCODER_AUDIO) { + AudioData(packet); + } else if (packet->type == OBS_ENCODER_VIDEO) { + VideoData(packet); + } +} + +void MoQOutput::AudioData(struct encoder_packet *packet) +{ + obs_encoder_t *encoder = packet->encoder; + + auto it = audio_tracks.find(encoder); + if (it == audio_tracks.end()) { + AudioInit(encoder); + it = audio_tracks.find(encoder); + } + if (it == audio_tracks.end() || it->second < 0) { + // We failed to initialize the audio track, so we can't write any data. + return; + } + int handle = it->second; + + // Add ~1 second offset to handle negative PTS from audio priming frames. + // TODO: This is slightly wrong when den is not evenly divisible by num, but close enough. + int64_t pts = packet->pts + packet->timebase_den / packet->timebase_num; + if (pts < 0) { + LOG_WARNING("Dropping audio frame with negative PTS: %lld", (long long)packet->pts); + return; + } + + auto pts_us = util_mul_div64(pts, 1000000ULL * packet->timebase_num, packet->timebase_den); + + auto result = moq_publish_media_frame(handle, packet->data, packet->size, pts_us); + if (result < 0) { + LOG_ERROR("Failed to write audio frame: %d", result); + return; + } + + total_bytes_sent += packet->size; +} + +void MoQOutput::VideoData(struct encoder_packet *packet) +{ + obs_encoder_t *encoder = packet->encoder; + + auto it = video_tracks.find(encoder); + if (it == video_tracks.end()) { + VideoInit(encoder); + it = video_tracks.find(encoder); + } + if (it == video_tracks.end() || it->second < 0) + return; + int handle = it->second; + + // Add ~1 second offset to match audio for A/V sync. + // TODO: This is slightly wrong when den is not evenly divisible by num, but close enough. + int64_t pts = packet->pts + packet->timebase_den / packet->timebase_num; + if (pts < 0) { + LOG_WARNING("Dropping video frame with negative PTS: %lld", (long long)packet->pts); + return; + } + + auto pts_us = util_mul_div64(pts, 1000000ULL * packet->timebase_num, packet->timebase_den); + + auto result = moq_publish_media_frame(handle, packet->data, packet->size, pts_us); + if (result < 0) { + LOG_ERROR("Failed to write video frame: %d", result); + return; + } + + total_bytes_sent += packet->size; +} + +void MoQOutput::VideoInit(obs_encoder_t *encoder) +{ + if (!encoder) { + LOG_ERROR("Failed to get video encoder"); + return; + } + + // TODO Pass these along to the video catalog somehow. + /* + OBSDataAutoRelease settings = obs_encoder_get_settings(encoder); + if (!settings) { + LOG_ERROR("Failed to get video encoder settings"); + return; + } + + auto video_bitrate = (int)obs_data_get_int(settings, "bitrate"); + auto video_width = obs_encoder_get_width(encoder); + auto video_height = obs_encoder_get_height(encoder); + */ + + uint8_t *extra_data = nullptr; + size_t extra_size = 0; + + // obs_encoder_get_extra_data may only return data after the first frame has been encoded. + // For H.264, this returns the SPS/PPS + if (!obs_encoder_get_extra_data(encoder, &extra_data, &extra_size)) { + LOG_WARNING("Failed to get extra data"); + } + + const char *codec = obs_encoder_get_codec(encoder); + + // Transform codec string for MoQ + const char *moq_codec = codec; + if (strcmp(codec, "h264") == 0) { + // H.264 with inline SPS/PPS + moq_codec = "avc3"; + } else if (strcmp(codec, "hevc") == 0) { + // H.265 with inline VPS/SPS/PPS + moq_codec = "hev1"; + } + + // Intialize the media import module with the codec and initialization data. + int handle = moq_publish_media_ordered(broadcast, moq_codec, strlen(moq_codec), extra_data, extra_size); + video_tracks[encoder] = handle; + if (handle < 0) { + LOG_ERROR("Failed to initialize video track: %d", handle); + return; + } + + LOG_INFO("Video track initialized successfully"); +} + +void MoQOutput::AudioInit(obs_encoder_t *encoder) +{ + if (!encoder) { + LOG_ERROR("Failed to get audio encoder"); + return; + } + + // TODO Pass these along to the audio catalog somehow. + /* + OBSDataAutoRelease settings = obs_encoder_get_settings(encoder); + if (!settings) { + LOG_ERROR("Failed to get audio encoder settings"); + return; + } + + auto audio_bitrate = (int)obs_data_get_int(settings, "bitrate"); + */ + + uint8_t *extra_data = nullptr; + size_t extra_size = 0; + + // obs_encoder_get_extra_data may only return data after the first frame has been encoded. + // For AAC, this returns 2 bytes containing the profile and the sample rate. + if (!obs_encoder_get_extra_data(encoder, &extra_data, &extra_size)) { + LOG_WARNING("Failed to get extra data"); + } + + const char *codec = obs_encoder_get_codec(encoder); + + int handle = moq_publish_media_ordered(broadcast, codec, strlen(codec), extra_data, extra_size); + audio_tracks[encoder] = handle; + if (handle < 0) { + LOG_ERROR("Failed to initialize audio track: %d", handle); + return; + } + + LOG_INFO("Audio track initialized successfully"); +} + +void register_moq_output() +{ + const uint32_t base_flags = OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE | OBS_OUTPUT_MULTI_TRACK_VIDEO | + OBS_OUTPUT_MULTI_TRACK_AUDIO; + + const char *audio_codecs = "aac;opus"; + const char *video_codecs = "h264;hevc;av1"; + + struct obs_output_info info = {}; + info.id = "moq_output"; + info.flags = OBS_OUTPUT_AV | base_flags; + info.get_name = [](void *) -> const char * { + return "MoQ Output"; + }; + info.create = [](obs_data_t *settings, obs_output_t *output) -> void * { + return new MoQOutput(settings, output); + }; + info.destroy = [](void *priv_data) { + delete static_cast(priv_data); + }; + info.start = [](void *priv_data) -> bool { + return static_cast(priv_data)->Start(); + }; + info.stop = [](void *priv_data, uint64_t) { + static_cast(priv_data)->Stop(); + }; + info.encoded_packet = [](void *priv_data, struct encoder_packet *packet) { + static_cast(priv_data)->Data(packet); + }; + info.get_total_bytes = [](void *priv_data) -> uint64_t { + return (uint64_t)static_cast(priv_data)->GetTotalBytes(); + }; + info.get_connect_time_ms = [](void *priv_data) -> int { + return static_cast(priv_data)->GetConnectTime(); + }; + info.encoded_video_codecs = video_codecs; + info.encoded_audio_codecs = audio_codecs; + info.protocols = "MoQ"; + + obs_register_output(&info); + + info.id = "moq_output_video"; + info.flags = OBS_OUTPUT_VIDEO | base_flags; + info.encoded_audio_codecs = nullptr; + obs_register_output(&info); + + info.id = "moq_output_audio"; + info.flags = OBS_OUTPUT_AUDIO | base_flags; + info.encoded_video_codecs = nullptr; + info.encoded_audio_codecs = audio_codecs; + obs_register_output(&info); +} diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h new file mode 100644 index 0000000000..6911e70065 --- /dev/null +++ b/cpp/obs/src/moq-output.h @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once +#include + +#include +#include +#include +#include +#include +#include +#include "logger.h" + +class MoQOutput { +public: + MoQOutput(obs_data_t *settings, obs_output_t *output); + ~MoQOutput(); + + bool Start(); + void Stop(bool signal = true); + void Data(struct encoder_packet *packet); + + inline size_t GetTotalBytes() { return total_bytes_sent; } + + inline int GetConnectTime() { return connect_time_ms; } + +private: + void VideoInit(obs_encoder_t *encoder); + void VideoData(struct encoder_packet *packet); + void AudioInit(obs_encoder_t *encoder); + void AudioData(struct encoder_packet *packet); + + obs_output_t *output; + + std::string server_url; + std::string path; + + size_t total_bytes_sent; + // Written by the session status callback (libmoq runtime thread), read by + // GetConnectTime() (OBS thread); atomic to avoid a data race. + std::atomic connect_time_ms; + std::chrono::steady_clock::time_point connect_start; + + int origin; + int session; + int broadcast; + + // Session subscription lifetime. libmoq delivers a terminal status callback + // (code <= 0) asynchronously on its runtime thread after moq_session_close, + // and may touch `this` until then. outstanding_sessions counts sessions whose + // terminal callback hasn't fired; the destructor waits for it to reach zero + // so a late callback can't touch freed memory. + std::mutex session_mutex; + std::condition_variable session_cv; + int outstanding_sessions; + + std::map video_tracks; + std::map audio_tracks; +}; + +void register_moq_output(); diff --git a/cpp/obs/src/moq-service.cpp b/cpp/obs/src/moq-service.cpp new file mode 100644 index 0000000000..3be6d2cf6c --- /dev/null +++ b/cpp/obs/src/moq-service.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "moq-service.h" + +// TODO: Define supported codecs. +const char *audio_codecs[] = {"aac", "opus", nullptr}; +const char *video_codecs[] = {"h264", "hevc", "av1", nullptr}; + +MoQService::MoQService(obs_data_t *settings, obs_service_t *) : server(), path() +{ + Update(settings); +} + +void MoQService::Update(obs_data_t *settings) +{ + server = obs_data_get_string(settings, "server"); + path = obs_data_get_string(settings, "key"); +} + +obs_properties_t *MoQService::Properties() +{ + obs_properties_t *ppts = obs_properties_create(); + + // Adds properties to be modified by the UI. + // obs_property_t *obs_properties_add_text(obs_properties_t *props, const char *name, const char *desc, enum obs_text_type type) + obs_properties_add_text(ppts, "server", "URL", OBS_TEXT_DEFAULT); + obs_properties_add_text(ppts, "key", "Path (optional)", OBS_TEXT_DEFAULT); + + return ppts; +} + +void MoQService::ApplyEncoderSettings(obs_data_t *video_settings, obs_data_t *audio_settings) +{ + /* + This function is called to apply custom encoder settings specific to this service. + For example, if a service requires a specific keyframe interval, or has a bitrate limit, + the settings for the video and audio encoders can be optionally modified + if the front-end optionally calls. + */ + + // Example: + if (video_settings) { + obs_data_set_int(video_settings, "bf", 0); + obs_data_set_bool(video_settings, "repeat_headers", true); + } + + if (audio_settings) { + obs_data_set_int(audio_settings, "bf", 0); + } +} + +const char *MoQService::GetConnectInfo(enum obs_service_connect_info type) +{ + switch (type) { + case OBS_SERVICE_CONNECT_INFO_SERVER_URL: + return server.c_str(); + case OBS_SERVICE_CONNECT_INFO_STREAM_KEY: + return path.c_str(); + default: + return nullptr; + } +} + +bool MoQService::CanTryToConnect() +{ + return !server.empty(); +} + +void register_moq_service() +{ + struct obs_service_info info = {}; + + info.id = "moq_service"; + info.get_name = [](void *) -> const char * { + return "MoQ (Debug)"; + }; + info.create = [](obs_data_t *settings, obs_service_t *service) -> void * { + return new MoQService(settings, service); + }; + info.destroy = [](void *priv_data) { + delete static_cast(priv_data); + }; + info.update = [](void *priv_data, obs_data_t *settings) { + static_cast(priv_data)->Update(settings); + }; + info.get_properties = [](void *) -> obs_properties_t * { + return MoQService::Properties(); + }; + info.get_protocol = [](void *) -> const char * { + return "MoQ"; + }; + info.get_url = [](void *priv_data) -> const char * { + return static_cast(priv_data)->server.c_str(); + }; + info.get_output_type = [](void *) -> const char * { + return "moq_output"; + }; + info.apply_encoder_settings = [](void *, obs_data_t *video_settings, obs_data_t *audio_settings) { + MoQService::ApplyEncoderSettings(video_settings, audio_settings); + }; + info.get_supported_video_codecs = [](void *) -> const char ** { + return video_codecs; + }; + info.get_supported_audio_codecs = [](void *) -> const char ** { + return audio_codecs; + }; + info.can_try_to_connect = [](void *priv_data) -> bool { + return static_cast(priv_data)->CanTryToConnect(); + }; + info.get_connect_info = [](void *priv_data, uint32_t type) -> const char * { + return static_cast(priv_data)->GetConnectInfo((enum obs_service_connect_info)type); + }; + obs_register_service(&info); +} diff --git a/cpp/obs/src/moq-service.h b/cpp/obs/src/moq-service.h new file mode 100644 index 0000000000..5b8720fd89 --- /dev/null +++ b/cpp/obs/src/moq-service.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once +#include +#include + +struct MoQService { + // TODO: Define needed params to connect to a relay + std::string server; + std::string path; + + MoQService(obs_data_t *settings, obs_service_t *service); + + void Update(obs_data_t *settings); + static obs_properties_t *Properties(); + static void ApplyEncoderSettings(obs_data_t *video_settings, obs_data_t *audio_settings); + bool CanTryToConnect(); + const char *GetConnectInfo(enum obs_service_connect_info type); +}; + +void register_moq_service(); diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp new file mode 100644 index 0000000000..4da3d1cdc5 --- /dev/null +++ b/cpp/obs/src/moq-source.cpp @@ -0,0 +1,1183 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#ifdef _WIN32 +#define strncasecmp _strnicmp +#endif +extern "C" { +#include +#include +#include +#include +#include "moq.h" +} + +#include "moq-source.h" +#include "logger.h" + +// Map codec string from moq_video_config to FFmpeg codec ID +static AVCodecID codec_string_to_id(const char *codec, size_t len) +{ + if (!codec || len == 0) { + return AV_CODEC_ID_NONE; + } + + // H.264/AVC + if ((len >= 4 && strncasecmp(codec, "h264", 4) == 0) || (len >= 3 && strncasecmp(codec, "avc", 3) == 0)) { + return AV_CODEC_ID_H264; + } + + // HEVC/H.265 + if ((len >= 4 && strncasecmp(codec, "hevc", 4) == 0) || (len >= 4 && strncasecmp(codec, "h265", 4) == 0) || + (len >= 4 && strncasecmp(codec, "hev1", 4) == 0) || (len >= 4 && strncasecmp(codec, "hvc1", 4) == 0)) { + return AV_CODEC_ID_HEVC; + } + + // VP9 + if ((len >= 3 && strncasecmp(codec, "vp9", 3) == 0) || (len >= 4 && strncasecmp(codec, "vp09", 4) == 0)) { + return AV_CODEC_ID_VP9; + } + + // AV1 + if ((len >= 3 && strncasecmp(codec, "av1", 3) == 0) || (len >= 4 && strncasecmp(codec, "av01", 4) == 0)) { + return AV_CODEC_ID_AV1; + } + + // VP8 + if (len >= 3 && strncasecmp(codec, "vp8", 3) == 0) { + return AV_CODEC_ID_VP8; + } + + return AV_CODEC_ID_NONE; +} + +struct moq_source { + obs_source_t *source; + + // Settings - current active connection settings + char *url; + char *broadcast; + + // Shutdown flag - set when destroy begins, callbacks should exit early + std::atomic shutting_down; + + // Lifetime reference count, guarded by mutex. ctx must outlive every libmoq + // subscription that was handed `ctx` as user_data: each delivers exactly one + // terminal callback (status <= 0), the documented last touch of user_data + // (libmoq >= 0.3.0). We hold one reference for the OBS-owned source plus one + // per outstanding subscription (session, catalog, video track); a + // subscription's reference is released by its terminal callback (see + // subscription_ref). destroy drops the owner reference and waits for the + // count to reach zero before freeing. + int refs; + pthread_cond_t refs_zero; // signaled when refs reaches 0 + + // Session handles (all negative = invalid) + std::atomic generation; // Increments on reconnect + bool reconnect_in_progress; // True while reconnect is happening + int32_t origin; + int32_t session; + int32_t consume; + int32_t catalog_handle; + int32_t video_track; + + // Decoder state + AVCodecContext *codec_ctx; + AVCodecID current_codec_id; // Currently configured codec + enum AVPixelFormat current_pix_fmt; // Current pixel format for sws_ctx + struct SwsContext *sws_ctx; + bool got_keyframe; + uint32_t frames_waiting_for_keyframe; // Count of skipped frames while waiting + uint32_t consecutive_decode_errors; // Count of consecutive decode failures + + // Output frame buffer + struct obs_source_frame frame; + uint8_t *frame_buffer; + + // Threading + pthread_mutex_t mutex; +}; + +// RAII helper that releases a subscription's lifetime reference when its async +// callback returns. libmoq (>= 0.3.0) hands `ctx` to each subscription as +// user_data and guarantees exactly one terminal callback (status code <= 0), +// after which user_data is never touched again. Each callback constructs one of +// these with `terminal` set on that terminal code; on scope exit it drops the +// matching reference and wakes moq_source_destroy when the last one is gone. +// Releasing on scope exit (not entry) keeps ctx valid for the whole callback +// body, including a terminal callback that still reads ctx before returning. +// +// Because libmoq runs all callbacks on a single runtime thread, a subscription's +// reference is held continuously from registration through its terminal +// callback, so ctx is always valid on entry to any of its callbacks - no +// shutting_down pre-check is needed for safety. +namespace { +struct subscription_ref { + struct moq_source *ctx; + bool terminal; + + subscription_ref(struct moq_source *c, bool is_terminal) : ctx(c), terminal(is_terminal) {} + + ~subscription_ref() + { + if (!terminal) + return; + pthread_mutex_lock(&ctx->mutex); + if (--ctx->refs == 0) + pthread_cond_broadcast(&ctx->refs_zero); + pthread_mutex_unlock(&ctx->mutex); + } + + subscription_ref(const subscription_ref &) = delete; + subscription_ref &operator=(const subscription_ref &) = delete; +}; +} // namespace + +// Forward declarations +static void moq_source_update(void *data, obs_data_t *settings); +static void moq_source_destroy(void *data); +static obs_properties_t *moq_source_properties(void *data); +static void moq_source_get_defaults(obs_data_t *settings); + +// MoQ callbacks +static void on_session_status(void *user_data, int32_t code); +static void on_catalog(void *user_data, int32_t catalog); +static void on_video_frame(void *user_data, int32_t frame_id); + +// Helper functions +static void moq_source_reconnect(struct moq_source *ctx); +static void moq_source_disconnect_locked(struct moq_source *ctx); +static void moq_source_blank_video(struct moq_source *ctx); +static bool moq_source_init_decoder(struct moq_source *ctx, const struct moq_video_config *config); +static void moq_source_destroy_decoder_locked(struct moq_source *ctx); +static void moq_source_decode_frame(struct moq_source *ctx, int32_t frame_id); + +static void *moq_source_create(obs_data_t *settings, obs_source_t *source) +{ + struct moq_source *ctx = (struct moq_source *)bzalloc(sizeof(struct moq_source)); + ctx->source = source; + + // Initialize shutdown flag + ctx->shutting_down = false; + + // One lifetime reference for the OBS-owned source itself; each subscription + // adds its own while outstanding. + ctx->refs = 1; + pthread_cond_init(&ctx->refs_zero, NULL); + + // Initialize handles to invalid values + ctx->generation = 0; + ctx->reconnect_in_progress = false; + ctx->origin = -1; + ctx->session = -1; + ctx->consume = -1; + ctx->catalog_handle = -1; + ctx->video_track = -1; + + // Initialize decoder state + ctx->codec_ctx = NULL; + ctx->current_codec_id = AV_CODEC_ID_NONE; + ctx->current_pix_fmt = AV_PIX_FMT_NONE; + ctx->sws_ctx = NULL; + ctx->got_keyframe = false; + ctx->frames_waiting_for_keyframe = 0; + ctx->consecutive_decode_errors = 0; + ctx->frame_buffer = NULL; + + // Initialize threading + pthread_mutex_init(&ctx->mutex, NULL); + + // Initialize OBS frame structure - dimensions will be set dynamically from stream + ctx->frame.width = 0; + ctx->frame.height = 0; + ctx->frame.format = VIDEO_FORMAT_RGBA; + ctx->frame.linesize[0] = 0; + + // Load settings from OBS - this will auto-connect if settings are valid + // (moq_source_update detects settings changed from NULL and reconnects) + moq_source_update(ctx, settings); + + return ctx; +} + +static void moq_source_destroy(void *data) +{ + struct moq_source *ctx = (struct moq_source *)data; + + pthread_mutex_lock(&ctx->mutex); + ctx->shutting_down = true; + + // Close every subscription. Each was handed `ctx` as user_data and now + // delivers its terminal callback, which releases the matching reference. + // Closing via the handle makes that terminal fire promptly (libmoq's close + // path wins over any pending update), so the wait below is real quiescence, + // not a timing guess. + moq_source_disconnect_locked(ctx); + + // Drop the owner reference, then wait for the outstanding subscriptions to + // deliver their terminal callbacks before freeing ctx. The generous bounded + // wait is a backstop against a subscription that never terminates (a libmoq + // bug or an unaccounted handle): far better to log and proceed than to hang + // OBS on source deletion. In normal operation the terminals arrive within + // milliseconds and the timeout is never reached. + bool timed_out = false; + if (--ctx->refs > 0) { + struct timespec deadline; + timespec_get(&deadline, TIME_UTC); + deadline.tv_sec += 2; + while (ctx->refs > 0) { + if (pthread_cond_timedwait(&ctx->refs_zero, &ctx->mutex, &deadline) == ETIMEDOUT) { + LOG_WARNING("Teardown timed out with %d MoQ callback(s) still outstanding; " + "leaking ctx to avoid a use-after-free", + ctx->refs); + timed_out = true; + break; + } + } + } + pthread_mutex_unlock(&ctx->mutex); + + // A subscription callback still holds ctx (it references ctx->mutex, + // ctx->refs, ctx->refs_zero). Freeing now would be a use-after-free when + // that callback fires, so intentionally leak instead. This only happens on + // the abnormal timeout path above. + if (timed_out) + return; + + bfree(ctx->url); + bfree(ctx->broadcast); + // Note: frame_buffer is already freed by moq_source_disconnect_locked + + pthread_cond_destroy(&ctx->refs_zero); + pthread_mutex_destroy(&ctx->mutex); + + bfree(ctx); +} + +// Relay URLs can embed credentials (userinfo) or a query/path token, and OBS +// logs are frequently shared for support. Reduce a URL to scheme://host[:port] +// for logging so secrets never reach persistent logs. +static std::string redact_url(const char *url) +{ + if (!url || !*url) + return "(null)"; + + std::string s(url); + size_t scheme = s.find("://"); + std::string prefix = (scheme == std::string::npos) ? "" : s.substr(0, scheme + 3); + size_t rest = (scheme == std::string::npos) ? 0 : scheme + 3; + + // The authority ends at the first '/', '?' or '#'. + size_t auth_end = s.find_first_of("/?#", rest); + std::string authority = s.substr(rest, auth_end == std::string::npos ? std::string::npos : auth_end - rest); + + // Drop any userinfo (user:pass@). Use the last '@' so an unescaped '@' in a + // password can't leave part of it behind. + size_t at = authority.rfind('@'); + if (at != std::string::npos) + authority = authority.substr(at + 1); + + return prefix + authority; +} + +static void moq_source_update(void *data, obs_data_t *settings) +{ + struct moq_source *ctx = (struct moq_source *)data; + + const char *url = obs_data_get_string(settings, "url"); + const char *broadcast = obs_data_get_string(settings, "broadcast"); + + pthread_mutex_lock(&ctx->mutex); + + // Check if settings actually changed + bool url_changed = (!ctx->url && url && strlen(url) > 0) || (ctx->url && !url) || + (ctx->url && url && strcmp(ctx->url, url) != 0); + bool broadcast_changed = (!ctx->broadcast && broadcast && strlen(broadcast) > 0) || + (ctx->broadcast && !broadcast) || + (ctx->broadcast && broadcast && strcmp(ctx->broadcast, broadcast) != 0); + bool settings_changed = url_changed || broadcast_changed; + + // Store the new settings + bfree(ctx->url); + ctx->url = bstrdup(url); + bfree(ctx->broadcast); + ctx->broadcast = bstrdup(broadcast); + + // Check if new settings are valid for connection + bool valid = ctx->url && ctx->broadcast && strlen(ctx->url) > 0 && strlen(ctx->broadcast) > 0; + + pthread_mutex_unlock(&ctx->mutex); + + // If settings changed and are valid, reconnect + if (settings_changed && valid) { + LOG_INFO("Settings changed, reconnecting (url=%s, broadcast=%s)", redact_url(url).c_str(), + broadcast ? broadcast : "(null)"); + moq_source_reconnect(ctx); + } else if (settings_changed && !valid) { + LOG_INFO("Settings changed but invalid - disconnecting"); + pthread_mutex_lock(&ctx->mutex); + moq_source_disconnect_locked(ctx); + pthread_mutex_unlock(&ctx->mutex); + moq_source_blank_video(ctx); + } +} + +static void moq_source_get_defaults(obs_data_t *settings) +{ + obs_data_set_default_string(settings, "url", "http://localhost:4443"); + obs_data_set_default_string(settings, "broadcast", "obs/test"); +} + +static obs_properties_t *moq_source_properties(void *data) +{ + UNUSED_PARAMETER(data); + + obs_properties_t *props = obs_properties_create(); + + obs_properties_add_text(props, "url", "URL", OBS_TEXT_DEFAULT); + obs_properties_add_text(props, "broadcast", "Broadcast", OBS_TEXT_DEFAULT); + + return props; +} + +// Forward declaration for use in callback +static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_gen); + +// MoQ callback implementations +static void on_session_status(void *user_data, int32_t code) +{ + struct moq_source *ctx = (struct moq_source *)user_data; + + // Hold this session subscription's reference for the callback's lifetime. A + // terminal status (<= 0) means the session task has ended and will not touch + // ctx again, so the reference is released when `ref` goes out of scope. + subscription_ref ref(ctx, code <= 0); + + pthread_mutex_lock(&ctx->mutex); + if (ctx->shutting_down.load()) { + // Teardown in progress; nothing to do (a terminal callback still + // releases its reference via `ref`). + pthread_mutex_unlock(&ctx->mutex); + return; + } + if (ctx->session < 0) { + LOG_DEBUG("Ignoring session status callback - already disconnected"); + pthread_mutex_unlock(&ctx->mutex); + return; + } + uint32_t current_gen = ctx->generation; + + // libmoq status codes (>= 0.3.0): + // > 0 : (re)connected, carrying the connection epoch (1 = first connect, + // 2 = first reconnect, ...). The session auto-reconnects internally. + // = 0 : closed cleanly via moq_session_close (terminal) - we initiated it. + // < 0 : reconnect permanently gave up or fatal error (terminal). + if (code > 0) { + pthread_mutex_unlock(&ctx->mutex); + LOG_INFO("MoQ session connected (generation %u, epoch %d)", current_gen, code); + // Start consuming only on the first connect. On later epochs libmoq has + // re-subscribed our existing consumer automatically (the origin outlives + // the connection), so recreating it would leak handles. + if (code == 1) { + moq_source_start_consume(ctx, current_gen); + } + } else if (code == 0) { + // Clean close - we asked for this (disconnect/reconnect/destroy). The + // handle is already being torn down; nothing to do here. + pthread_mutex_unlock(&ctx->mutex); + LOG_DEBUG("MoQ session closed cleanly (generation %u)", current_gen); + } else { + // Terminal error (e.g. auth failure, or reconnect gave up). Tear down + // every subscription, not just the session, so the catalog/video + // subscriptions also fire their terminal callbacks and release their + // references promptly instead of lingering until the source is destroyed. + LOG_ERROR("MoQ session error: %d (generation %u)", code, current_gen); + moq_source_disconnect_locked(ctx); + pthread_mutex_unlock(&ctx->mutex); + + // Blank the video to show error state + moq_source_blank_video(ctx); + } +} + +static void on_catalog(void *user_data, int32_t catalog) +{ + struct moq_source *ctx = (struct moq_source *)user_data; + + // Hold the catalog subscription's reference for the callback's lifetime; + // release it when this is the terminal callback (catalog <= 0). + subscription_ref ref(ctx, catalog <= 0); + + if (catalog <= 0) { + if (catalog < 0) { + LOG_ERROR("Catalog subscription error: %d", catalog); + // Surface the failure (e.g. invalid broadcast) by blanking, as the + // old catalog-fetch-failed path did - but not during our own teardown. + if (!ctx->shutting_down.load()) + moq_source_blank_video(ctx); + } else { + LOG_DEBUG("Catalog subscription closed cleanly"); + } + return; + } + + LOG_INFO("Catalog callback received: %d", catalog); + + // `catalog` is a catalog *snapshot* id (a different slab from the + // subscription handle stored in ctx->catalog_handle). It must be freed with + // moq_consume_catalog_free on every path below - never closed. + pthread_mutex_lock(&ctx->mutex); + bool stale = ctx->shutting_down.load() || ctx->consume < 0; + uint32_t current_gen = ctx->generation; + pthread_mutex_unlock(&ctx->mutex); + if (stale) { + // Disconnected or shutting down; ignore this snapshot. + moq_consume_catalog_free(catalog); + return; + } + + // Get video configuration from the snapshot. + struct moq_video_config video_config; + if (moq_consume_video_config(catalog, 0, &video_config) < 0) { + LOG_ERROR("Failed to get video config"); + moq_consume_catalog_free(catalog); + return; + } + + // Initialize decoder with the video config (takes mutex internally, and + // copies the codec/description out of the snapshot). + if (!moq_source_init_decoder(ctx, &video_config)) { + LOG_ERROR("Failed to initialize decoder"); + moq_consume_catalog_free(catalog); + return; + } + + // Pre-account for the video track subscription before handing ctx to libmoq, + // so its reference is in place the instant the subscription exists. Undone + // below only if creation fails. + pthread_mutex_lock(&ctx->mutex); + ctx->refs++; + pthread_mutex_unlock(&ctx->mutex); + + // Subscribe to the video track (index 0). This takes the catalog snapshot, + // not the consume handle, and does not retain it - so free the snapshot + // immediately after. + int32_t track = moq_consume_video_ordered(catalog, 0, 0, on_video_frame, ctx); + moq_consume_catalog_free(catalog); + if (track < 0) { + LOG_ERROR("Failed to subscribe to video track: %d", track); + pthread_mutex_lock(&ctx->mutex); + // No subscription was created, so no terminal will fire; undo the ref. + if (--ctx->refs == 0) + pthread_cond_broadcast(&ctx->refs_zero); + pthread_mutex_unlock(&ctx->mutex); + return; + } + + // The video track subscription now exists and will deliver a terminal + // on_video_frame (<= 0) that releases the reference added above - even on the + // cleanup path below. + pthread_mutex_lock(&ctx->mutex); + if (ctx->generation == current_gen && !ctx->shutting_down.load()) { + // A catalog update can arrive while a track is already subscribed; close + // the previous one so its terminal callback releases its reference (else + // it would linger until teardown's bounded wait). + int32_t old_track = ctx->video_track; + ctx->video_track = track; + pthread_mutex_unlock(&ctx->mutex); + if (old_track >= 0) + moq_consume_video_close(old_track); + LOG_INFO("Subscribed to video track successfully"); + } else { + // Stale or shutting down: close the track we just created; its terminal + // callback releases the reference added above. + pthread_mutex_unlock(&ctx->mutex); + moq_consume_video_close(track); + } +} + +static void on_video_frame(void *user_data, int32_t frame_id) +{ + struct moq_source *ctx = (struct moq_source *)user_data; + + // Hold the video track subscription's reference for the callback's lifetime + // (which includes the FFmpeg decode in moq_source_decode_frame); release it + // on the terminal callback (frame_id <= 0). + subscription_ref ref(ctx, frame_id <= 0); + + if (frame_id <= 0) { + if (frame_id < 0) + LOG_ERROR("Video track error: %d", frame_id); + else + LOG_DEBUG("Video track closed cleanly"); + return; + } + + pthread_mutex_lock(&ctx->mutex); + if (ctx->shutting_down.load() || ctx->consume < 0) { + // Shutting down or disconnected: drop the frame. + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + pthread_mutex_unlock(&ctx->mutex); + + moq_source_decode_frame(ctx, frame_id); +} + +// Helper function implementations +static void moq_source_reconnect(struct moq_source *ctx) +{ + // Increment generation to invalidate old callbacks + pthread_mutex_lock(&ctx->mutex); + + // Never start a new connection once teardown has begun: it would register a + // subscription after destroy already closed everything, leaking its + // reference until the bounded wait times out. (OBS serializes update/destroy + // so this is defense-in-depth.) + if (ctx->shutting_down.load()) { + pthread_mutex_unlock(&ctx->mutex); + return; + } + + // Check if reconnect is already in progress + if (ctx->reconnect_in_progress) { + LOG_DEBUG("Reconnect already in progress, skipping"); + pthread_mutex_unlock(&ctx->mutex); + return; + } + + ctx->reconnect_in_progress = true; + uint32_t new_gen = ctx->generation.load() + 1; + LOG_INFO("Reconnecting (generation %u -> %u)", ctx->generation.load(), new_gen); + ctx->generation.store(new_gen); + moq_source_disconnect_locked(ctx); + + // Copy URL while holding mutex for thread safety + char *url_copy = bstrdup(ctx->url); + pthread_mutex_unlock(&ctx->mutex); + + // Blank video while reconnecting to avoid showing stale frames + moq_source_blank_video(ctx); + + // No delay needed before reconnecting: libmoq origins and sessions are fully + // independent (each origin is a distinct random instance, each session its own + // task), so the new connection shares no client-side state with the one we just + // closed. moq_origin_close removes the origin synchronously, and moq_session_close + // only signals the old session's task to wind down asynchronously on the libmoq + // runtime thread - nothing the new origin/session can collide with. (The previous + // os_sleep_ms(50) here was a timing band-aid that provided no real guarantee.) + + // Create origin for consuming (outside mutex since it may block) + int32_t new_origin = moq_origin_create(); + if (new_origin < 0) { + LOG_ERROR("Failed to create origin: %d", new_origin); + bfree(url_copy); + pthread_mutex_lock(&ctx->mutex); + ctx->reconnect_in_progress = false; + pthread_mutex_unlock(&ctx->mutex); + return; + } + + // Pre-account for the session subscription before handing ctx to libmoq: the + // connection can fail and fire its terminal on_session_status from the + // runtime thread immediately, and that must not decrement the reference + // before we have added it. + pthread_mutex_lock(&ctx->mutex); + ctx->refs++; + pthread_mutex_unlock(&ctx->mutex); + + // Connect to MoQ server (consume will happen in on_session_status callback) + int32_t new_session = moq_session_connect(url_copy, strlen(url_copy), + 0, // origin_publish + new_origin, // origin_consume + on_session_status, ctx); + bfree(url_copy); + + if (new_session < 0) { + LOG_ERROR("Failed to connect to MoQ server: %d", new_session); + moq_origin_close(new_origin); + pthread_mutex_lock(&ctx->mutex); + // No session subscription was created, so no terminal will fire; undo + // the reference we pre-added above. + if (--ctx->refs == 0) + pthread_cond_broadcast(&ctx->refs_zero); + ctx->reconnect_in_progress = false; + pthread_mutex_unlock(&ctx->mutex); + return; + } + + // Now update ctx with the new handles, checking if generation changed + pthread_mutex_lock(&ctx->mutex); + if (ctx->generation != new_gen) { + // Another reconnect happened while we were creating origin/session + // Clean up our newly created resources + ctx->reconnect_in_progress = false; + pthread_mutex_unlock(&ctx->mutex); + LOG_INFO("Generation changed during reconnect setup, cleaning up stale resources"); + moq_session_close(new_session); + moq_origin_close(new_origin); + return; + } + ctx->origin = new_origin; + ctx->session = new_session; + ctx->reconnect_in_progress = false; + LOG_INFO("Connecting to MoQ server (generation %u)", new_gen); + pthread_mutex_unlock(&ctx->mutex); +} + +// Called after session is connected successfully +static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_gen) +{ + // Check if origin is still valid and generation matches + pthread_mutex_lock(&ctx->mutex); + if (ctx->origin < 0 || ctx->generation != expected_gen) { + pthread_mutex_unlock(&ctx->mutex); + LOG_INFO("Skipping stale consume (generation mismatch or invalid origin)"); + return; + } + // Capture values while holding mutex + int32_t origin = ctx->origin; + char *broadcast_copy = bstrdup(ctx->broadcast); + pthread_mutex_unlock(&ctx->mutex); + + // Consume broadcast by path + int32_t consume = moq_origin_consume(origin, broadcast_copy, strlen(broadcast_copy)); + if (consume < 0) { + LOG_ERROR("Failed to consume broadcast '%s': %d", broadcast_copy, consume); + bfree(broadcast_copy); + // Failed to consume - clean up session/origin + pthread_mutex_lock(&ctx->mutex); + if (ctx->generation == expected_gen) { + if (ctx->session >= 0) { + moq_session_close(ctx->session); + ctx->session = -1; + } + if (ctx->origin >= 0) { + moq_origin_close(ctx->origin); + ctx->origin = -1; + } + } + pthread_mutex_unlock(&ctx->mutex); + moq_source_blank_video(ctx); + return; + } + + pthread_mutex_lock(&ctx->mutex); + // Verify generation hasn't changed while we were waiting + if (ctx->generation != expected_gen) { + pthread_mutex_unlock(&ctx->mutex); + LOG_INFO("Generation changed during consume setup, cleaning up"); + moq_consume_close(consume); + bfree(broadcast_copy); + return; + } + ctx->consume = consume; + pthread_mutex_unlock(&ctx->mutex); + + // Pre-account for the catalog subscription before handing ctx to libmoq, so + // its reference is in place the instant the subscription exists (see the + // note on subscription_ref). Undone below only if creation fails. + pthread_mutex_lock(&ctx->mutex); + ctx->refs++; + pthread_mutex_unlock(&ctx->mutex); + + // Subscribe to catalog updates + int32_t catalog_handle = moq_consume_catalog(consume, on_catalog, ctx); + if (catalog_handle < 0) { + LOG_ERROR("Failed to subscribe to catalog for '%s': %d", broadcast_copy, catalog_handle); + bfree(broadcast_copy); + // Failed to get catalog - clean up + pthread_mutex_lock(&ctx->mutex); + // No subscription was created, so no terminal will fire; undo the ref. + if (--ctx->refs == 0) + pthread_cond_broadcast(&ctx->refs_zero); + if (ctx->generation == expected_gen) { + if (ctx->consume >= 0) { + moq_consume_close(ctx->consume); + ctx->consume = -1; + } + if (ctx->session >= 0) { + moq_session_close(ctx->session); + ctx->session = -1; + } + if (ctx->origin >= 0) { + moq_origin_close(ctx->origin); + ctx->origin = -1; + } + } + pthread_mutex_unlock(&ctx->mutex); + moq_source_blank_video(ctx); + return; + } + + // The catalog subscription now exists and will deliver a terminal on_catalog + // (<= 0) that releases the reference added above. Store the subscription + // handle so disconnect can close it, which makes that terminal fire promptly. + // (This is the real subscription handle, distinct from the catalog snapshot + // ids delivered to on_catalog.) + pthread_mutex_lock(&ctx->mutex); + if (ctx->generation == expected_gen && !ctx->shutting_down.load()) { + ctx->catalog_handle = catalog_handle; + pthread_mutex_unlock(&ctx->mutex); + } else { + // Stale or shutting down: close it; its terminal releases the reference. + pthread_mutex_unlock(&ctx->mutex); + moq_consume_catalog_close(catalog_handle); + } + + LOG_INFO("Consuming broadcast: %s", broadcast_copy); + bfree(broadcast_copy); +} + +// NOTE: Caller must hold ctx->mutex when calling this function. +// +// The moq_*_close calls below only *signal* each subscription to wind down; +// libmoq delivers the terminal callback asynchronously on its runtime thread, +// never synchronously from within close(). That is what lets us close under the +// mutex here without the terminal callback's subscription_ref re-entering the +// mutex on this same thread (which would self-deadlock). +static void moq_source_disconnect_locked(struct moq_source *ctx) +{ + if (ctx->video_track >= 0) { + moq_consume_video_close(ctx->video_track); + ctx->video_track = -1; + } + + if (ctx->catalog_handle >= 0) { + moq_consume_catalog_close(ctx->catalog_handle); + ctx->catalog_handle = -1; + } + + if (ctx->consume >= 0) { + moq_consume_close(ctx->consume); + ctx->consume = -1; + } + + if (ctx->session >= 0) { + moq_session_close(ctx->session); + ctx->session = -1; + } + + if (ctx->origin >= 0) { + moq_origin_close(ctx->origin); + ctx->origin = -1; + } + + moq_source_destroy_decoder_locked(ctx); + ctx->got_keyframe = false; + ctx->frames_waiting_for_keyframe = 0; + ctx->consecutive_decode_errors = 0; +} + +// Blanks the video preview by outputting a NULL frame +static void moq_source_blank_video(struct moq_source *ctx) +{ + // Passing NULL to obs_source_output_video clears the current frame + obs_source_output_video(ctx->source, NULL); + LOG_DEBUG("Video preview blanked"); +} + +static bool moq_source_init_decoder(struct moq_source *ctx, const struct moq_video_config *config) +{ + // Map codec string to FFmpeg codec ID dynamically + AVCodecID codec_id = codec_string_to_id(config->codec, config->codec_len); + if (codec_id == AV_CODEC_ID_NONE) { + // Log the codec string for debugging (may not be null-terminated) + char codec_str[64] = {0}; + size_t copy_len = config->codec_len < sizeof(codec_str) - 1 ? config->codec_len : sizeof(codec_str) - 1; + if (config->codec && copy_len > 0) { + memcpy(codec_str, config->codec, copy_len); + } + LOG_ERROR("Unknown or unsupported codec: '%s'", codec_str); + return false; + } + + // Find decoder for the codec + const AVCodec *codec = avcodec_find_decoder(codec_id); + if (!codec) { + LOG_ERROR("Decoder not found for codec ID: %d", codec_id); + return false; + } + + // Create codec context (can be done outside mutex) + AVCodecContext *new_codec_ctx = avcodec_alloc_context3(codec); + if (!new_codec_ctx) { + LOG_ERROR("Failed to allocate codec context"); + return false; + } + + // Get dimensions from config - required for buffer allocation + uint32_t width = 0; + uint32_t height = 0; + + if (config->coded_width && *config->coded_width > 0) { + new_codec_ctx->width = *config->coded_width; + width = *config->coded_width; + } + if (config->coded_height && *config->coded_height > 0) { + new_codec_ctx->height = *config->coded_height; + height = *config->coded_height; + } + + // Use codec description as extradata (contains SPS/PPS for H.264, VPS/SPS/PPS for HEVC, etc.) + if (config->description && config->description_len > 0) { + new_codec_ctx->extradata = + (uint8_t *)av_mallocz(config->description_len + AV_INPUT_BUFFER_PADDING_SIZE); + if (new_codec_ctx->extradata) { + memcpy(new_codec_ctx->extradata, config->description, config->description_len); + new_codec_ctx->extradata_size = static_cast(config->description_len); + } + } + + // Open codec + if (avcodec_open2(new_codec_ctx, codec, NULL) < 0) { + LOG_ERROR("Failed to open codec"); + avcodec_free_context(&new_codec_ctx); + return false; + } + + // If dimensions weren't in config, try to get them from the opened codec context + // (may have been parsed from extradata) + if (width == 0 && new_codec_ctx->width > 0) { + width = new_codec_ctx->width; + } + if (height == 0 && new_codec_ctx->height > 0) { + height = new_codec_ctx->height; + } + + // Now take the mutex and swap in the new decoder state + pthread_mutex_lock(&ctx->mutex); + + // Destroy old decoder state + if (ctx->sws_ctx) { + sws_freeContext(ctx->sws_ctx); + } + if (ctx->codec_ctx) { + avcodec_free_context(&ctx->codec_ctx); + } + if (ctx->frame_buffer) { + bfree(ctx->frame_buffer); + } + + // Install new decoder state + // Note: sws_ctx, frame_buffer, and frame dimensions will be initialized + // dynamically on first decoded frame when we know the actual pixel format + ctx->codec_ctx = new_codec_ctx; + ctx->current_codec_id = codec_id; + ctx->current_pix_fmt = AV_PIX_FMT_NONE; // Will be set on first frame + ctx->sws_ctx = NULL; // Will be created on first frame with actual pixel format + ctx->frame_buffer = NULL; // Will be allocated on first frame with actual dimensions + ctx->frame.width = width; + ctx->frame.height = height; + ctx->frame.linesize[0] = width * 4; + ctx->frame.data[0] = NULL; + ctx->frame.format = VIDEO_FORMAT_RGBA; + ctx->frame.timestamp = 0; + ctx->got_keyframe = false; + ctx->frames_waiting_for_keyframe = 0; + ctx->consecutive_decode_errors = 0; + + pthread_mutex_unlock(&ctx->mutex); + + // Log codec name for debugging + char codec_str[64] = {0}; + size_t copy_len = config->codec_len < sizeof(codec_str) - 1 ? config->codec_len : sizeof(codec_str) - 1; + if (config->codec && copy_len > 0) { + memcpy(codec_str, config->codec, copy_len); + } + LOG_INFO("Decoder initialized: codec=%s, dimensions=%ux%u (may be refined on first frame)", codec_str, width, + height); + return true; +} + +// NOTE: Caller must hold ctx->mutex when calling this function +static void moq_source_destroy_decoder_locked(struct moq_source *ctx) +{ + if (ctx->sws_ctx) { + sws_freeContext(ctx->sws_ctx); + ctx->sws_ctx = NULL; + } + + if (ctx->codec_ctx) { + avcodec_free_context(&ctx->codec_ctx); + ctx->codec_ctx = NULL; + } + + if (ctx->frame_buffer) { + bfree(ctx->frame_buffer); + ctx->frame_buffer = NULL; + ctx->frame.data[0] = NULL; + } + + // Reset dynamic format tracking + ctx->current_codec_id = AV_CODEC_ID_NONE; + ctx->current_pix_fmt = AV_PIX_FMT_NONE; +} + +static void moq_source_decode_frame(struct moq_source *ctx, int32_t frame_id) +{ + // Fast path: check atomic flag before taking lock + if (ctx->shutting_down.load()) { + moq_consume_frame_close(frame_id); + return; + } + + pthread_mutex_lock(&ctx->mutex); + + // Double-check after acquiring lock (may have changed) + if (ctx->shutting_down.load()) { + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Check if decoder is still valid (may have been destroyed during reconnect) + // Note: sws_ctx and frame_buffer may be NULL on first frame - they're created dynamically + if (!ctx->codec_ctx) { + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Get frame data + struct moq_frame frame_data; + if (moq_consume_frame(frame_id, &frame_data) < 0) { + LOG_ERROR("Failed to get frame data"); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Skip non-keyframes until we get the first one + if (!ctx->got_keyframe && !frame_data.keyframe) { + ctx->frames_waiting_for_keyframe++; + if (ctx->frames_waiting_for_keyframe == 1 || (ctx->frames_waiting_for_keyframe % 30) == 0) { + LOG_INFO("Waiting for keyframe... (skipped %u frames so far)", + ctx->frames_waiting_for_keyframe); + } + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Mark that we've received a keyframe from the stream + if (frame_data.keyframe) { + if (!ctx->got_keyframe) { + LOG_INFO("Got keyframe after waiting for %u frames, payload_size=%zu", + ctx->frames_waiting_for_keyframe, frame_data.payload_size); + // Flush decoder to ensure clean state when starting from keyframe + avcodec_flush_buffers(ctx->codec_ctx); + } + ctx->got_keyframe = true; + ctx->frames_waiting_for_keyframe = 0; + ctx->consecutive_decode_errors = 0; + } + + // Create AVPacket from frame data + AVPacket *packet = av_packet_alloc(); + if (!packet) { + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + packet->data = (uint8_t *)frame_data.payload; + packet->size = static_cast(frame_data.payload_size); + packet->pts = frame_data.timestamp_us / 1000; // Convert to milliseconds + packet->dts = packet->pts; + + // Send packet to decoder + int ret = avcodec_send_packet(ctx->codec_ctx, packet); + av_packet_free(&packet); + + if (ret < 0) { + if (ret != AVERROR(EAGAIN)) { + ctx->consecutive_decode_errors++; + char errbuf[AV_ERROR_MAX_STRING_SIZE]; + av_strerror(ret, errbuf, sizeof(errbuf)); + + // If too many consecutive errors, flush decoder and wait for next keyframe + if (ctx->consecutive_decode_errors >= 5) { + LOG_WARNING("Too many send errors (%u), flushing decoder and waiting for keyframe", + ctx->consecutive_decode_errors); + avcodec_flush_buffers(ctx->codec_ctx); + ctx->got_keyframe = false; + ctx->consecutive_decode_errors = 0; + } else if (ctx->consecutive_decode_errors == 1) { + LOG_ERROR("Error sending packet to decoder: %s", errbuf); + } + } + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Receive decoded frames + AVFrame *frame = av_frame_alloc(); + if (!frame) { + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + ret = avcodec_receive_frame(ctx->codec_ctx, frame); + if (ret < 0) { + if (ret != AVERROR(EAGAIN)) { + ctx->consecutive_decode_errors++; + char errbuf[AV_ERROR_MAX_STRING_SIZE]; + av_strerror(ret, errbuf, sizeof(errbuf)); + + // If too many consecutive errors, flush decoder and wait for next keyframe + if (ctx->consecutive_decode_errors >= 5) { + LOG_WARNING("Too many decode errors (%u), flushing decoder and waiting for keyframe", + ctx->consecutive_decode_errors); + avcodec_flush_buffers(ctx->codec_ctx); + ctx->got_keyframe = false; + ctx->consecutive_decode_errors = 0; + } else if (ctx->consecutive_decode_errors == 1) { + // Only log first error in a sequence + LOG_ERROR("Error receiving frame from decoder: %s", errbuf); + } + } + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Successfully decoded a frame - reset error counter + ctx->consecutive_decode_errors = 0; + + // Check if we need to (re)initialize the scaler - either first frame, dimension change, or pixel format change + enum AVPixelFormat decoded_pix_fmt = (enum AVPixelFormat)frame->format; + bool dimensions_changed = (frame->width != (int)ctx->frame.width || frame->height != (int)ctx->frame.height); + bool pix_fmt_changed = (decoded_pix_fmt != ctx->current_pix_fmt); + bool need_reinit = (!ctx->sws_ctx || !ctx->frame_buffer || dimensions_changed || pix_fmt_changed); + + if (need_reinit) { + if (dimensions_changed) { + LOG_INFO("Decoded frame dimensions changed: %ux%u -> %dx%d", ctx->frame.width, + ctx->frame.height, frame->width, frame->height); + } + if (pix_fmt_changed) { + LOG_INFO("Decoded frame pixel format changed: %d -> %d (%s)", ctx->current_pix_fmt, + decoded_pix_fmt, + av_get_pix_fmt_name(decoded_pix_fmt) ? av_get_pix_fmt_name(decoded_pix_fmt) + : "unknown"); + } + + // Validate that dimensions are positive and reasonable + if (frame->width <= 0 || frame->height <= 0 || frame->width > 16384 || frame->height > 16384) { + LOG_ERROR("Invalid decoded frame dimensions: %dx%d", frame->width, frame->height); + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Validate pixel format is supported by swscale + if (decoded_pix_fmt == AV_PIX_FMT_NONE) { + LOG_ERROR("Invalid decoded frame pixel format: %d", decoded_pix_fmt); + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Free old sws context + if (ctx->sws_ctx) { + sws_freeContext(ctx->sws_ctx); + ctx->sws_ctx = NULL; + } + + // Create new scaling context with the actual pixel format from the decoded frame + struct SwsContext *new_sws_ctx = sws_getContext(frame->width, frame->height, decoded_pix_fmt, + frame->width, frame->height, AV_PIX_FMT_RGBA, + SWS_BILINEAR, NULL, NULL, NULL); + if (!new_sws_ctx) { + LOG_ERROR("Failed to create scaling context for %dx%d pix_fmt=%d (%s)", frame->width, + frame->height, decoded_pix_fmt, + av_get_pix_fmt_name(decoded_pix_fmt) ? av_get_pix_fmt_name(decoded_pix_fmt) + : "unknown"); + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Reallocate frame buffer for new dimensions (width * height * 4 for RGBA) + size_t new_buffer_size = (size_t)frame->width * (size_t)frame->height * 4; + uint8_t *new_frame_buffer = (uint8_t *)bmalloc(new_buffer_size); + if (!new_frame_buffer) { + LOG_ERROR("Failed to allocate frame buffer for %dx%d (%zu bytes)", frame->width, frame->height, + new_buffer_size); + sws_freeContext(new_sws_ctx); + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Free old frame buffer + if (ctx->frame_buffer) { + bfree(ctx->frame_buffer); + } + + // Install new state + ctx->sws_ctx = new_sws_ctx; + ctx->current_pix_fmt = decoded_pix_fmt; + ctx->frame_buffer = new_frame_buffer; + ctx->frame.width = frame->width; + ctx->frame.height = frame->height; + ctx->frame.linesize[0] = frame->width * 4; + ctx->frame.data[0] = new_frame_buffer; + + LOG_INFO("Scaler initialized for %dx%d pix_fmt=%s", frame->width, frame->height, + av_get_pix_fmt_name(decoded_pix_fmt) ? av_get_pix_fmt_name(decoded_pix_fmt) : "unknown"); + } + + // Convert YUV420P to RGBA + uint8_t *dst_data[4] = {ctx->frame_buffer, NULL, NULL, NULL}; + int dst_linesize[4] = {static_cast(ctx->frame.width * 4), 0, 0, 0}; + + sws_scale(ctx->sws_ctx, (const uint8_t *const *)frame->data, frame->linesize, 0, ctx->frame.height, dst_data, + dst_linesize); + + // Update OBS frame timestamp and output. OBS expects nanoseconds; libmoq + // delivers microseconds. + ctx->frame.timestamp = frame_data.timestamp_us * 1000; + obs_source_output_video(ctx->source, &ctx->frame); + + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); +} + +// Registration function +void register_moq_source() +{ + struct obs_source_info info = {}; + info.id = "moq_source"; + info.type = OBS_SOURCE_TYPE_INPUT; + info.output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_DO_NOT_DUPLICATE; + info.get_name = [](void *) -> const char * { + return "Moq Source (MoQ)"; + }; + info.create = moq_source_create; + info.destroy = moq_source_destroy; + info.update = moq_source_update; + info.get_defaults = moq_source_get_defaults; + info.get_properties = moq_source_properties; + + obs_register_source(&info); +} diff --git a/cpp/obs/src/moq-source.h b/cpp/obs/src/moq-source.h new file mode 100644 index 0000000000..292fb76fdb --- /dev/null +++ b/cpp/obs/src/moq-source.h @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +void register_moq_source(); diff --git a/cpp/obs/src/obs-moq.cpp b/cpp/obs/src/obs-moq.cpp new file mode 100644 index 0000000000..052b977705 --- /dev/null +++ b/cpp/obs/src/obs-moq.cpp @@ -0,0 +1,76 @@ +/* +SPDX-License-Identifier: GPL-2.0-or-later +Plugin Name +Copyright (C) + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include + +#include "moq-output.h" +#include "moq-service.h" +#include "moq-source.h" + +#ifdef MOQ_FRONTEND_ENABLED +#include "moq-dock.h" +#endif + +extern "C" { +#include "moq.h" +} + +#ifdef _WIN64 +#include +#include +#include +#include +#endif + +OBS_DECLARE_MODULE() +OBS_MODULE_USE_DEFAULT_LOCALE("obs-moq", "en-US") +MODULE_EXPORT const char *obs_module_description(void) +{ + return "OBS MoQ (Media over QUIC) module"; +} + +bool obs_module_load(void) +{ + // On Windows, allocate a console when RUST_LOG=debug *before* initializing + // the Rust logger below, so its output binds to a valid stderr. AllocConsole + // sets the process std handles, but the C runtime streams must be reopened + // onto the console device for that output to be visible. +#ifdef _WIN64 + const char *logLevel = std::getenv("RUST_LOG"); + if (logLevel && strcmp(logLevel, "debug") == 0) { + AllocConsole(); + freopen("CONOUT$", "w", stdout); + freopen("CONOUT$", "w", stderr); + } +#endif + + // Use RUST_LOG env var for more verbose output + // The second argument is the string length of the first argument. + moq_log_level("info", 4); + + register_moq_output(); + register_moq_service(); + register_moq_source(); + +#ifdef MOQ_FRONTEND_ENABLED + register_moq_dock(); +#endif + + return true; +} diff --git a/demo/justfile b/demo/justfile index c3d0097162..9bc29b25fc 100644 --- a/demo/justfile +++ b/demo/justfile @@ -21,12 +21,13 @@ default: echo ">>> Port 4443 is busy, using $port instead" >&2 fi - export MOQ_SERVER_BIND="[::]:$port" - export MOQ_WEB_HTTP_LISTEN="[::]:$port" base="http://localhost:$port" + # Scope the bind env vars to the relay only. The unified `moq` binary also + # reads MOQ_SERVER_BIND, so exporting it globally would make the `pub` client + # try to start a server (and fail without a TLS cert). bun run concurrently --kill-others --names rly,bbb,web --prefix-colors auto \ - "just relay" \ + "MOQ_SERVER_BIND='[::]:$port' MOQ_WEB_HTTP_LISTEN='[::]:$port' just relay" \ "just wait $base/certificate.sha256 && just pub bbb $base" \ "just wait $base/certificate.sha256 && just web serve $base" diff --git a/demo/pub/justfile b/demo/pub/justfile index 3b2541b859..01ac4a559f 100644 --- a/demo/pub/justfile +++ b/demo/pub/justfile @@ -53,25 +53,25 @@ tos url='http://localhost:4443' *args: # Publish a video using ffmpeg (CMAF / fMP4) to a relay server. cmaf name url='http://localhost:4443' *args: - cargo build --bin moq-cli + cargo build --bin moq just ffmpeg-cmaf "media/{{ name }}.mp4" |\ - cargo run --bin moq-cli -- \ - {{ args }} publish --url "{{ url }}" --name "{{ name }}.hang" fmp4 + cargo run --bin moq -- \ + {{ args }} --client-connect "{{ url }}" --broadcast "{{ name }}.hang" import fmp4 # Publish a video using ffmpeg (MPEG-TS) to a relay server. ts name url='http://localhost:4443' *args: - cargo build --bin moq-cli + cargo build --bin moq just ffmpeg-ts "media/{{ name }}.mp4" |\ - cargo run --bin moq-cli -- \ - {{ args }} publish --url "{{ url }}" --name "{{ name }}.hang" ts + cargo run --bin moq -- \ + {{ args }} --client-connect "{{ url }}" --broadcast "{{ name }}.hang" import ts # Publish a video via iroh. iroh name url prefix="": just download "{{ name }}" - cargo build --bin moq-cli + cargo build --bin moq just ffmpeg-cmaf "media/{{ name }}.mp4" |\ - cargo run --bin moq-cli -- \ - --iroh-enabled publish --url "{{ url }}" --name "{{ prefix }}{{ name }}.hang" fmp4 + cargo run --bin moq -- \ + --iroh-enabled --client-connect "{{ url }}" --broadcast "{{ prefix }}{{ name }}.hang" import fmp4 # Generate and ingest an HLS stream from a video file. hls name relay="http://localhost:4443": @@ -143,7 +143,7 @@ hls name relay="http://localhost:4443": trap cleanup SIGINT SIGTERM EXIT echo ">>> Importing HLS via moq-cli" - cargo run --bin moq-cli -- hls --url "{{ relay }}" import --broadcast "{{ name }}.hang" --playlist "$OUT_DIR/master.m3u8" + cargo run --bin moq -- --client-connect "{{ relay }}" --broadcast "{{ name }}.hang" import hls "$OUT_DIR/master.m3u8" EXIT_CODE=$? cleanup @@ -152,7 +152,7 @@ hls name relay="http://localhost:4443": # Publish a video using H.264 Annex B format. h264 name url="http://localhost:4443" *args: just download "{{ name }}" - cargo build --bin moq-cli + cargo build --bin moq ffmpeg -hide_banner -v quiet \ -stream_loop -1 -re \ @@ -160,16 +160,16 @@ h264 name url="http://localhost:4443" *args: -c:v copy -an \ -bsf:v h264_mp4toannexb \ -f h264 \ - - | cargo run --bin moq-cli -- publish --url "{{ url }}" --name "{{ name }}.hang" avc3 {{ args }} + - | cargo run --bin moq -- {{ args }} --client-connect "{{ url }}" --broadcast "{{ name }}.hang" import avc3 # Publish a video using ffmpeg directly from moq to the localhost. serve name *args: just download "{{ name }}" - cargo build --bin moq-cli + cargo build --bin moq just ffmpeg-cmaf "media/{{ name }}.mp4" |\ - cargo run --bin moq-cli -- \ - {{ args }} serve --listen "[::]:4443" --tls-generate "localhost" \ - --name "{{ name }}.hang" fmp4 + cargo run --bin moq -- \ + {{ args }} --server-bind "[::]:4443" --tls-generate "localhost" \ + --broadcast "{{ name }}.hang" import fmp4 # Generate and serve an HLS stream from a video for testing. serve-hls name port="8000": diff --git a/demo/web/src/highlight.ts b/demo/web/src/highlight.ts index ce9a52c33c..e4668295a7 100644 --- a/demo/web/src/highlight.ts +++ b/demo/web/src/highlight.ts @@ -6,6 +6,7 @@ import typescript from "highlight.js/lib/languages/typescript"; import html from "highlight.js/lib/languages/xml"; import "highlight.js/styles/atom-one-dark.css"; + hljs.configure({ cssSelector: "code", }); diff --git a/demo/web/src/index.ts b/demo/web/src/index.ts index 3a3345bbca..c6ede092e7 100644 --- a/demo/web/src/index.ts +++ b/demo/web/src/index.ts @@ -105,7 +105,6 @@ function createTile(name: string): WatchTile { // `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. diff --git a/deny.toml b/deny.toml index d68707220f..db69795297 100644 --- a/deny.toml +++ b/deny.toml @@ -26,14 +26,18 @@ ignore = [ # requires Rust 1.92; workspace MSRV is 1.85. Build-time only. "RUSTSEC-2024-0436", - # http-cache (via http-cache-reqwest in moq-relay) still depends on - # bincode 1.x. Awaits upstream bump to bincode 2.x. - "RUSTSEC-2025-0141", - # proc-macro-error2 unmaintained. Pulled in transitively as a build-time # proc-macro via local-ip-address -> neli -> getset; no runtime exposure. # Drop the ignore when getset migrates off proc-macro-error2. "RUSTSEC-2026-0173", + + # quick-xml 0.39 DoS on untrusted XML (quadratic attribute check, unbounded + # namespace allocation). Fixed in 0.41, but the only consumer is + # plist -> netdev -> netwatch -> iroh, and even the latest plist pins + # quick-xml 0.39. netdev only parses local macOS system configuration, not + # attacker-controlled XML. Drop both when plist moves to quick-xml >= 0.41. + "RUSTSEC-2026-0194", + "RUSTSEC-2026-0195", ] [licenses] diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 079bc18697..504cd56aeb 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -5,7 +5,9 @@ description: Command-line tools for MoQ media # FFmpeg / moq-cli -`moq-cli` is a command-line tool for publishing media to MoQ relays. It works with FFmpeg for encoding. +`moq-cli` is a media router: it wires one endpoint onto a shared MoQ Origin. It +moves media into MoQ from a source, or out of MoQ to a sink, bridging stdin/stdout +(via FFmpeg), HLS, RTMP, SRT, and WebRTC. ## Installation @@ -39,7 +41,7 @@ docker pull moqdev/moq-cli # 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 + docker run -i moqdev/moq-cli --client-connect https://relay.example.com/anon --broadcast my-stream.hang import ts ``` Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub](https://hub.docker.com/r/moqdev/moq-cli). @@ -54,11 +56,37 @@ cargo build --release --bin moq-cli The binary will be in `target/release/moq-cli`. +## The grammar + +``` +moq [endpoint options] +``` + +- **MoQ side** attaches the Origin to the network, and comes before the verb. At + least one of: + + - `--client-connect ` dials a relay. The URL path is the relay auth path + (e.g. `/anon`), `?jwt=` supplies a token, and `--broadcast` names the + broadcast. + - `--server-bind ` hosts MoQ sessions directly (with `--tls-generate` / + `--tls-cert` + `--tls-key`). + + Both may be given at once (dial a relay *and* accept incoming sessions). +- **`import`** routes media INTO MoQ (a source fills the Origin); **`export`** + routes it OUT (a sink drains the Origin). The verb fixes the data direction. +- **endpoint** is one subcommand: a container format (`avc3`, `fmp4`, `ts`, `flv` + read from stdin on import; `fmp4`, `mkv`, `ts`, `flv` written to stdout on + export), or a gateway (`hls`, `rtmp`, `srt`, `rtc`). For the bidirectional + gateways, `--connect` dials out and `--listen` binds a socket; the parent verb + decides whether that pushes or pulls. + +Run `moq import --help` / `moq export --help` to see the endpoints, and +`moq import rtmp --help` for a specific one. + ## 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. +`moq import ` reads a container from stdin; +`moq export ` writes one to stdout. ### Publish a Video File @@ -66,15 +94,7 @@ Remux a file to MPEG-TS and pipe it in (`-c copy` avoids re-encoding): ```bash ffmpeg -i video.mp4 -c copy -f mpegts - | \ - moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts -``` - -### Publish from FFmpeg - -Pipe FFmpeg output directly to moq-cli: - -```bash -ffmpeg -i input.mp4 -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts + moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang import ts ``` ### Capture a Webcam @@ -89,22 +109,22 @@ Build (or run) with the feature enabled: ```bash cargo build --release -p moq-cli --features capture # or run straight from a checkout: -cargo run -p moq-cli --features capture -- publish --url https://relay.example.com --broadcast cam.hang capture +cargo run -p moq-cli --features capture -- --client-connect https://relay.example.com/anon --broadcast cam.hang import capture # Default camera + microphone, hardware-encoded H.264 when available: -moq-cli publish --url https://relay.example.com --broadcast cam.hang capture +moq --client-connect https://relay.example.com/anon --broadcast cam.hang import capture # Pick devices, resolution, and bitrates: -moq-cli publish --url https://relay.example.com --broadcast cam.hang \ - capture --camera 0 --width 1280 --height 720 --fps 30 --bitrate 3000000 \ - --microphone "MacBook Pro Microphone" --audio-bitrate 64000 +moq --client-connect https://relay.example.com/anon --broadcast cam.hang \ + import capture --camera 0 --width 1280 --height 720 --fps 30 --bitrate 3000000 \ + --microphone "MacBook Pro Microphone" --audio-bitrate 64000 # One medium only: -moq-cli publish --url https://relay.example.com --broadcast cam.hang capture --no-audio -moq-cli publish --url https://relay.example.com --broadcast cam.hang capture --no-video +moq --client-connect https://relay.example.com/anon --broadcast cam.hang import capture --no-audio +moq --client-connect https://relay.example.com/anon --broadcast cam.hang import capture --no-video # Pick a codec (default h264). h265 is hardware-only: -moq-cli publish --url https://relay.example.com --broadcast cam.hang capture --codec h265 +moq --client-connect https://relay.example.com/anon --broadcast cam.hang import capture --codec h265 ``` On Linux the NVENC (NVIDIA) and VAAPI (Intel/AMD) encoders are compiled in by @@ -132,34 +152,24 @@ Alternatively, pipe an external FFmpeg process as MPEG-TS: ```bash # macOS -ffmpeg -f avfoundation -i "0:0" -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast webcam ts +ffmpeg -f avfoundation -i "0:0" -f mpegts - | \ + moq --client-connect https://relay.example.com/anon --broadcast webcam.hang import ts # Linux -ffmpeg -f v4l2 -i /dev/video0 -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast webcam ts +ffmpeg -f v4l2 -i /dev/video0 -f mpegts - | \ + moq --client-connect https://relay.example.com/anon --broadcast webcam.hang import ts ``` -### Publish Screen +### Play a Broadcast -```bash -# macOS -ffmpeg -f avfoundation -i "1:" -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast screen ts +Pull a broadcast back out and play it: -# Linux (X11) -ffmpeg -f x11grab -i :0.0 -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast screen ts +```bash +moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang export fmp4 | ffplay - ``` ## Encoding Options -### Custom Video Settings - -```bash -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 --url https://relay.example.com/anon --broadcast my-stream ts -``` - ### Low Latency Settings ```bash @@ -167,24 +177,15 @@ ffmpeg -i input.mp4 \ -c:v libx264 -preset ultrafast -tune zerolatency \ -g 30 -keyint_min 30 \ -c:a aac \ - -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts -``` - -### H.265/HEVC - -```bash -ffmpeg -i input.mp4 \ - -c:v libx265 -preset ultrafast \ - -c:a aac \ - -f mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts + -f mpegts - | moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang import ts ``` ## Container Formats -`publish` selects its input container with a subcommand; `subscribe` selects its -output container with `--format`. +The container format is the endpoint subcommand: `import ` reads it from +stdin, `export ` writes it to stdout. -Publish (read from stdin unless noted): +Import formats: - `avc3` - raw H.264 Annex-B - `fmp4` - fragmented MP4 / CMAF @@ -192,14 +193,14 @@ Publish (read from stdin unless noted): - `flv` - FLV / RTMP (H.264 video, AAC audio) - `capture` - capture local devices directly (camera H.264 + microphone Opus; requires the `capture` build feature; does not read stdin) -Subscribe (`--format`): +Export formats: - `fmp4` - fragmented MP4 / CMAF - `mkv` - Matroska / WebM - `ts` - MPEG-TS - `flv` - FLV / RTMP (H.264 video, AAC audio) -`subscribe` also takes `--catalog` to pick which catalog track to read for track +`export` also takes `--catalog-format` to pick which catalog track to read for track discovery. When omitted, it's auto-detected from the broadcast name suffix (`.hang` -> `hang`, `.msf` -> `msf`), falling back to `hang`: @@ -207,33 +208,25 @@ discovery. When omitted, it's auto-detected from the broadcast name suffix - `hangz` - the DEFLATE-compressed `catalog.json.z` catalog (opt-in; shares the `.hang` suffix and is never auto-detected) - `msf` - the MSF `catalog` track -### HLS / LL-HLS - -Import an HLS master/media playlist into a MoQ broadcast: - -```bash -moq-cli hls --url https://relay.example.com/anon import \ - --broadcast my-stream.hang \ - --playlist https://example.com/live/master.m3u8 -``` - -Serve MoQ broadcasts as HLS / LL-HLS over HTTP: - -```bash -moq-cli hls --url https://relay.example.com/anon export --listen '[::]:8089' -``` +Every export sink caps how long a stalled group is waited on before the muxer +skips to a newer one. Each owns the knob so its default fits the transport: the +stdout containers and `rtmp` take `--latency-max` (default `500ms`), `hls` takes +`--latency-max` (default `10s`, generous so live GOPs aren't skipped while +segments build), and `srt` reuses its `--latency` (the receive buffer doubles as +the skip threshold). WebRTC (`rtc`) is real-time and doesn't buffer, so it has no +such knob. ### MPEG-TS Ingest an MPEG-TS stream from FFmpeg and play one back out: ```bash -# Publish: remux a file to MPEG-TS and pipe it in +# Import: remux a file to MPEG-TS and pipe it in ffmpeg -i input.mp4 -c copy -f mpegts - | \ - moq-cli publish --url https://relay.example.com --broadcast my-stream ts + moq --client-connect https://relay.example.com --broadcast my-stream.hang import ts -# Subscribe: pull MPEG-TS back out and play it -moq-cli subscribe --url https://relay.example.com --broadcast my-stream --format ts | ffplay - +# Export: pull MPEG-TS back out and play it +moq --client-connect https://relay.example.com --broadcast my-stream.hang export ts | ffplay - ``` TS export carries H.264 / H.265 as Annex-B and AAC as ADTS. Both in-band @@ -243,35 +236,110 @@ sources work: the parameter sets are read from the bitstream or the catalog Broadcast audio (MP2, AC-3, E-AC-3) is carried verbatim: complete, well-formed frames pass through byte-exact, never transcoded; malformed input is rejected -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. +rather than mis-described. Elementary streams the CLI does not decode (SCTE-35 +cues, teletext, DVB subtitles, ...) are carried verbatim too, one MoQ track per +PID, described in the catalog `mpegts` section, and survive `import ts` / +`export ts` end-to-end. ### FLV -Ingest an FLV stream from FFmpeg and play one back out: - ```bash -# Publish: remux a file to FLV and pipe it in +# Import: remux a file to FLV and pipe it in ffmpeg -i input.mp4 -c copy -f flv - | \ - moq-cli publish --url https://relay.example.com --broadcast my-stream flv + moq --client-connect https://relay.example.com --broadcast my-stream.hang import flv -# Subscribe: pull FLV back out and play it -moq-cli subscribe --url https://relay.example.com --broadcast my-stream --format flv | ffplay - +# Export: pull FLV back out and play it +moq --client-connect https://relay.example.com --broadcast my-stream.hang export flv | ffplay - ``` -FLV is the classic RTMP container: H.264 video carried as length-prefixed NALU -with an out-of-band avcC, and AAC audio carried raw with an out-of-band -AudioSpecificConfig. Both pass straight through to the catalog `description`. The -enhanced E-RTMP FourCC payloads (HEVC, AV1, Opus) and the older codecs (VP6, MP3) -are not supported. +FLV is the classic RTMP container: H.264 video and AAC audio, each with an +out-of-band header. The enhanced E-RTMP FourCC payloads (HEVC, AV1, Opus) and the +older codecs (VP6, MP3) are not supported on the stdin/stdout container path. + +## HLS / LL-HLS + +Import a remote HLS master/media playlist into a MoQ broadcast: + +```bash +moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang \ + import hls https://example.com/live/master.m3u8 +``` + +Serve one MoQ broadcast as HLS / LL-HLS over HTTP (reached at +`http://host:8089//master.m3u8`): + +```bash +moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang \ + export hls --listen '[::]:8089' +``` + +HLS export does not allow cross-origin browser access unless configured. Pass +`--cors-origin ` one or more times to allow specific origins, or +`--cors-origin '*'` to allow any browser origin. + +## Network Gateways (RTMP / SRT / WebRTC) + +The `rtmp`, `srt`, and `rtc` endpoints bridge other live protocols. Each takes +either `--connect ` (dial out) or `--listen ` (bind a socket), and the +parent verb decides the role: + +- **import `--listen`** accepts pushes only (an RTMP/SRT publish, a WHIP publish). +- **export `--listen`** serves plays only (an RTMP/SRT play, a WHEP play). + +A listener is directional: an import listener rejects plays, and an export +listener rejects publishes. The operator declares the direction; the connecting +peer can't choose. + +Every gateway is scoped to the single `--broadcast` (required for a `--listen`): +a listener bridges only that broadcast, ignoring the RTMP app/key and SRT stream +id. (Multi-broadcast routing by app/key belongs behind a relay, via the gateway +libraries' auth-aware API.) + +### RTMP ingest to a relay + +Accept OBS / FFmpeg RTMP pushes and forward one broadcast to a relay: + +```bash +moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang \ + import rtmp --listen '[::]:1935' +``` + +### Restream MoQ to Twitch (RTMP) + +Pull a broadcast from a relay and push it to a remote RTMP server: + +```bash +moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang \ + export rtmp --connect 'rtmp://live.twitch.tv/app/' +``` + +### SRT + +```bash +# Accept incoming SRT publishes as one broadcast and forward to a relay +moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang import srt --listen '[::]:9000' + +# Serve a broadcast to SRT players +moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang export srt --listen '[::]:9000' +``` + +### WebRTC (WHIP / WHEP) + +Direction picks the HTTP role: import `--listen` is a WHIP server, export +`--listen` is a WHEP server. Peers reach the broadcast at +`http://host:8080/`. + +```bash +# WHIP ingest: browsers publish one broadcast to us, we forward to a relay +moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang import rtc --listen '[::]:8080' + +# WHEP playback: serve a broadcast to browsers +moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang export rtc --listen '[::]:8080' +``` + +The WHIP/WHEP HTTP listener does not allow cross-origin browser access unless +configured. Pass `--cors-origin ` one or more times to allow specific +origins, or `--cors-origin '*'` to allow any browser origin. ## Authentication @@ -279,7 +347,7 @@ Pass a JWT token via the URL's `?jwt=` query parameter: ```bash ffmpeg -i video.mp4 -c copy -f mpegts - | \ - moq-cli publish --url "https://relay.example.com/?jwt=" --broadcast my-stream ts + moq --client-connect "https://relay.example.com/?jwt=" --broadcast my-stream.hang import ts ``` See [Authentication](/bin/relay/auth) for token generation. @@ -296,25 +364,13 @@ just pub bbb https://relay.example.com/anon just pub tos https://relay.example.com/anon ``` -## Clock Synchronization - -Publish and subscribe to clock broadcasts for testing: - -```bash -# Publish a clock -just pub clock publish https://relay.example.com/anon - -# Subscribe to a clock -just pub clock subscribe https://relay.example.com/anon -``` - ## Debugging ### Verbose Output ```bash ffmpeg -i video.mp4 -c copy -f mpegts - | \ - RUST_LOG=debug moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts + RUST_LOG=debug moq --client-connect https://relay.example.com/anon --broadcast my-stream.hang import ts ``` ### Check Connection diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index 69a6ccb1d0..77f4774f08 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -225,6 +225,7 @@ The first pad of each kind is always `video_0` / `audio_0` regardless of catalog | Video | VP8 | `video/x-vp8` | | Video | VP9 | `video/x-vp9` | | Audio | AAC | `audio/mpeg` (v4) | +| Audio | MP3 | `audio/mpeg` (v1/v2, layer 3) | | Audio | Opus | `audio/x-opus` | ### moqsrc (subscribe) diff --git a/doc/bin/index.md b/doc/bin/index.md index cab524d630..87c72e7602 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 mpegts - | moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts +ffmpeg -f avfoundation -i "0" -f mpegts - | moq --client-connect https://relay.example.com/anon --broadcast my-stream import ts ``` ## [moq-rtc](/bin/rtc) diff --git a/doc/bin/obs.md b/doc/bin/obs.md index 3638a0284c..dfe151add1 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -18,9 +18,52 @@ The OBS plugin allows you to: - **Publish** directly from OBS to a MoQ relay - **Subscribe** to MoQ broadcasts as an OBS source -## Repository +It loads into a stock OBS Studio install. You no longer need to build OBS from source to use it. -The plugin is maintained in a separate repository: [moq-dev/obs](https://github.com/moq-dev/obs) +## Building + +The plugin lives in-tree under `cpp/obs/`. It links `libmoq`, which is built from the in-tree `rs/libmoq` crate via cargo (CMake's `MOQ_LOCAL` points at the repo root by default), so there is no prebuilt release to download. + +### Linux (Nix) + +`libobs`, `Qt6`, and `ffmpeg` come from the dev shell; no system packages required. + +```bash +nix develop +just obs build +``` + +### macOS + +The macOS build is fully native, **not** Nix. The build spec (`cpp/obs/buildspec.json`) downloads the prebuilt obs-deps bundle (`libobs`, `Qt6`, and `ffmpeg`) on first configure, so no Homebrew packages are needed. + +Requirements: + +- Full **Xcode** (not just the Command Line Tools): `sudo xcode-select -s /Applications/Xcode.app` +- Run **outside** the Nix dev shell. The Nix toolchain sets `DEVELOPER_DIR`/`NIX_LDFLAGS`, which break the Xcode build. If you use direnv, run from a plain terminal or `exit` the shell first. + +```bash +just obs setup # downloads obs-deps, configures via the macOS preset +just obs build +just obs run # copies the plugin into ~/Library/Application Support/obs-studio/plugins and launches OBS +``` + +### Windows + +Needs Visual Studio 2022. Run from Git Bash (for `just`); the build spec downloads obs-deps the same way as macOS. + +```bash +just obs setup +just obs build +``` + +## Releases + +The plugin statically links `libmoq`, so it ships with every libmoq release rather than on its own schedule. The [`libmoq` workflow](https://github.com/moq-dev/moq/blob/main/.github/workflows/libmoq.yml) (triggered by a `libmoq-v*` tag) rebuilds the plugin against the libmoq release it just published, then cuts a matching `obs-moq-v` release with **macOS (arm64)** and **Windows (x64)** binaries. `cpp/obs/build.sh --libmoq-release ` drives each build (it fetches the prebuilt libmoq archive, so no second cargo build). + +The archives are **unsigned**, so macOS Gatekeeper and Windows SmartScreen will warn on first load (right-click → Open on macOS). Extract the archive into your OBS plugins directory: the `.plugin` bundle on macOS, or the `obs-moq/` folder (containing `bin/64bit/` + `data/`) on Windows. + +**Linux is build-from-source for now** (see the Linux section above). A prebuilt Linux binary isn't shipped: the plugin needs ffmpeg to decode subscribed video, and a Linux build links the nix/distro ffmpeg rather than the version OBS bundles, so it wouldn't load portably. (A future native decoder via `moq-video` would remove the ffmpeg dependency and let Linux ship a binary too.) ## Usage diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 198925d983..c8b03c9379 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -249,72 +249,76 @@ 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 +## Stream Listeners 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]`. +can also listen for the qmux wire format directly over a plain stream: TCP +(`--server-tcp-bind`) or a Unix socket (`--server-unix-bind`). These listeners +authenticate **through the same path as QUIC**: a JWT (carried in the moq-lite-05 +SETUP path as `/broadcast?jwt=`) is verified and scopes the session, so a +memory-safety bug in an out-of-process gateway can reach only what its users' +tokens permit. + +A connection with **no JWT** resolves through the same public-access rules as a +tokenless QUIC client (`--auth-public` / `[auth] public`) — nothing listener +specific. To let a local helper publish under a fixed prefix without a token, +grant it publicly, e.g. `--auth-public-publish .stats` for a stats publisher. ### 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" +[server] +bind = "[::]:443" # QUIC; omit to run stream-only + +[server.tcp] +bind = "127.0.0.1:4444" ``` -TCP carries no peer identity, so **any local process of any user** can connect. +TCP carries no peer identity, so it cannot be gated by peer credentials. 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. +logs a warning when the address is not loopback 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 +moq --client-connect "tcp://127.0.0.1:4444/my-broadcast.hang?jwt=$TOKEN" import fmp4 < 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. +A Unix socket lets the relay additionally gate the connecting process by its +kernel credentials (`SO_PEERCRED` / `LOCAL_PEERCRED`), so you can restrict +access to a specific worker user. Requires the relay to be built with the `uds` +feature. The allowlist (`--server-unix-allow-uid` / `-gid` / `-pid`) applies to +the `unix://` listener. ```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 +[server.unix] +bind = "/run/moq/internal.sock" + +# Each list is matched independently (AND across fields, OR within a field); +# an omitted field imposes no constraint. Empty = any local process. +[server.unix.allow] +uid = [1001] # 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. +A connection whose credentials fail the allowlist is dropped before its SETUP is +read. A pid requirement rejects peers whose PID the platform doesn't report +(e.g. some macOS versions). The credential allowlist is defense-in-depth on top +of the JWT, not a replacement for it. ```bash -moq publish unix:///run/moq/internal.sock --broadcast my-broadcast < video.mp4 +moq --client-connect "unix:///run/moq/internal.sock/?jwt=$TOKEN" --broadcast my-broadcast.hang import fmp4 < 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). +Stream 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 and the exact version is agreed up +front (the listener offers moq-lite-05, the only version that carries a request +path in-band, so a JWT/path can ride the SETUP). ## Example Configurations diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index ba0a96c68a..339f3a8253 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -37,14 +37,39 @@ level = "info" ### \[server] -QUIC/WebTransport server settings. +QUIC/WebTransport server settings. Optionally add plaintext qmux stream +listeners for trusted local workers. Every connection authenticates through the +same JWT / public-access path; QUIC additionally accepts an mTLS client +certificate, and Unix sockets add optional peer-credential gating. ```toml [server] -# Listen address for QUIC (UDP) -listen = "0.0.0.0:4443" +# QUIC (UDP) bind. Omit to run stream-only (no QUIC) when a tcp/unix listener +# is configured below. +bind = "[::]:443" + +# Plaintext qmux over TCP (no TLS, carries no peer identity). Trusted networks +# only; a non-loopback bind logs a warning. Requires the `tcp` build feature. +[server.tcp] +bind = "127.0.0.1:4444" + +# Plaintext qmux over a Unix socket, for local workers (e.g. the protocol +# gateways or a stats publisher). Requires the `uds` build feature. Restrict +# callers by peer credentials (each list AND across, OR within; empty = no +# constraint). +[server.unix] +bind = "/run/moq/internal.sock" + +[server.unix.allow] +uid = [1001] +# gid = [2000] +# pid = [12345] ``` +No-JWT connections on the stream transports resolve through the same +public-access rules as tokenless QUIC clients (see [`[auth]`](#auth) `public`). +See [Stream Listeners](/bin/relay/auth#stream-listeners) for details. + ### \[server.tls] TLS configuration for the QUIC endpoint. @@ -94,35 +119,6 @@ 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 -# Billing tier these sessions record stats under (default "internal"; empty = the -# default unprefixed tier). See the Stats section. -tier = "internal" - -# 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. @@ -237,10 +233,9 @@ fan-out shows up under `internal/*` (the default trusted-peer label) and never i the default-tier numbers. Trusted (non-JWT/public) traffic defaults to the `internal` tier, configurable -per source: `--cluster-tier` (relay-to-relay dials), `--internal-tier` (the -`[internal]` listener), and `--auth-mtls-tier` (mTLS peers, when the auth API -doesn't return a `tier`). Set any of them to a different label, or to `""` to -record on the default unprefixed tier. +per source: `--cluster-tier` (relay-to-relay dials) and `--auth-mtls-tier` +(mTLS peers, when the auth API doesn't return a `tier`). Set either to a +different label, or to `""` to record on the default unprefixed tier. Each per-broadcast frame is a JSON object mapping broadcast path to a cumulative counter snapshot. An entry surfaces on any tick where the @@ -252,13 +247,13 @@ counterpart no traffic can flow, so the entry is dropped: ```json { "demo/bbb": { - "announced": 1, "announced_closed": 0, + "announced": 1, "announced_closed": 0, "announced_bytes": 8, "broadcasts": 1, "broadcasts_closed": 0, "subscriptions": 5, "subscriptions_closed": 2, "bytes": 12345, "frames": 678, "groups": 9 }, "anon/foo": { - "announced": 1, "announced_closed": 0, + "announced": 1, "announced_closed": 0, "announced_bytes": 8, "broadcasts": 1, "broadcasts_closed": 0, "subscriptions": 2, "subscriptions_closed": 0, "bytes": 234, "frames": 12, "groups": 1 @@ -271,6 +266,11 @@ Field semantics: - `announced` / `announced_closed`: cumulative count of every broadcast announce/unannounce event on this `(tier, role)` slot, regardless of whether any subscription happened. Use this for "all known broadcasts". +- `announced_bytes`: cumulative broadcast-name length summed over each + announce and unannounce of this broadcast. It counts the name, not the + encoded message size, so a broadcast isn't charged for hop chains or + framing overhead (and the count is the same across protocol versions). + Separate from `bytes`, which is media payload. - `broadcasts` / `broadcasts_closed`: per-(broadcast, session) subscription sentinel. The first active subscription a peer session opens for a broadcast bumps `broadcasts`; the last one it closes bumps diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md index 8eda4974bb..5ae6b18863 100644 --- a/doc/concept/layer/hang.md +++ b/doc/concept/layer/hang.md @@ -52,7 +52,7 @@ The catalog is published on two tracks with identical content: `catalog.json` (p A publisher always serves both; a consumer reads whichever it prefers and defaults to the uncompressed `catalog.json`. The compression is the group-scoped `deflate-raw` ([RFC 1951](https://www.rfc-editor.org/rfc/rfc1951.html)) stream used by `@moq/json` / `moq-json`, interoperable between the browser and native. -To read the compressed track, opt in explicitly: pass `--catalog hangz` to `moq-cli subscribe`, `CatalogFormat::HangZ` in Rust, or `catalogFormat: "hangz"` to `@moq/watch`. +To read the compressed track, opt in explicitly: pass `--catalog-format hangz` to `moq export`, `CatalogFormat::HangZ` in Rust, or `catalogFormat: "hangz"` to `@moq/watch`. The `.hang` broadcast suffix is unchanged: the compressed track is an extra track on the same broadcast, not a different broadcast name. ### Audio diff --git a/doc/concept/standard/interop.md b/doc/concept/standard/interop.md index fa82e51d23..4c4efefa80 100644 --- a/doc/concept/standard/interop.md +++ b/doc/concept/standard/interop.md @@ -27,13 +27,13 @@ A test pattern plus tone, so you don't need a media file: ffmpeg -re -f lavfi -i testsrc=size=1280x720:rate=30 -f lavfi -i sine=frequency=440 \ -c:v libx264 -preset ultrafast -tune zerolatency -g 60 -c:a aac \ -f mp4 -movflags cmaf+frag_keyframe+empty_moov+default_base_moof - \ -| moq-cli publish --url https://your-relay.example.com --broadcast bbb.hang fmp4 +| moq --client-connect https://your-relay.example.com --broadcast bbb.hang import fmp4 ``` ## Subscribe ```bash -moq-cli subscribe --url https://your-relay.example.com --broadcast bbb.hang --format fmp4 | ffplay - +moq --client-connect https://your-relay.example.com --broadcast bbb.hang export fmp4 | ffplay - ``` If it plays, you interop. That's the whole test. diff --git a/doc/lib/js/@moq/watch.md b/doc/lib/js/@moq/watch.md index 2e26784596..55ff4bd38a 100644 --- a/doc/lib/js/@moq/watch.md +++ b/doc/lib/js/@moq/watch.md @@ -70,6 +70,7 @@ a real bundler (the examples below). - `paused`: Pause playback (boolean) - `muted`: Mute audio (boolean) - `volume`: Audio volume (0 to 1, default: 1) +- `reload`: Wait for (re)announcement before subscribing (default: true). Defaults off for `mediaoverquic.com` relays until they support broadcast discovery. - `latency`: Latency target. `"real-time"` (default) derives it from RTT, or a number sets a fixed jitter buffer in ms. Collapses `latency-min` and `latency-max` to one value (minimize latency). - `latency-min`: Latency floor (the jitter/startup buffer). Same units as `latency`; leaves the ceiling untouched. - `latency-max`: Latency ceiling. `"real-time"` (default) minimizes latency; a number caps at that many ms. A ceiling above the floor enables [buffered playback](#buffered-playback): build up a buffer from future-dated frames instead of skipping ahead. @@ -273,8 +274,10 @@ const broadcast = new Watch.Broadcast({ connection, enabled: true, name: "alice.hang", - reload: true, }); + +// Disable the announcement gate for relays without broadcast discovery. +broadcast.reload.set(false); ``` ## Related Packages diff --git a/doc/lib/rs/crate/hang.md b/doc/lib/rs/crate/hang.md index b783a40470..c7fc308295 100644 --- a/doc/lib/rs/crate/hang.md +++ b/doc/lib/rs/crate/hang.md @@ -134,14 +134,14 @@ cargo install moq-cli # 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 + moq --client-connect https://relay.example.com/anon --broadcast my-stream import ts # Publish from FFmpeg ffmpeg -i input.mp4 -f mpegts - | \ - moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts + moq --client-connect https://relay.example.com/anon --broadcast my-stream import ts ``` -See `moq-cli --help` for all options, or [FFmpeg documentation](/bin/cli). +See `moq --help` for all options, or [FFmpeg documentation](/bin/cli). ## API Reference diff --git a/doc/lib/rs/crate/moq-mux.md b/doc/lib/rs/crate/moq-mux.md index d8bee35255..5e6bd6511a 100644 --- a/doc/lib/rs/crate/moq-mux.md +++ b/doc/lib/rs/crate/moq-mux.md @@ -42,8 +42,8 @@ See the [moq-cli source](https://github.com/moq-dev/moq/tree/main/rs/moq-cli) fo **Video:** -- H.264 (AVC) - requires `h264` feature -- H.265 (HEVC) - requires `h265` feature +- H.264 (AVC) +- H.265 (HEVC) - AV1 - VP8 - VP9 @@ -52,6 +52,7 @@ See the [moq-cli source](https://github.com/moq-dev/moq/tree/main/rs/moq-cli) fo - AAC - Opus +- MP3 - MP2 (MPEG-TS only, carried verbatim) - AC-3 (MPEG-TS only, carried verbatim) - E-AC-3 (MPEG-TS only, carried verbatim) @@ -86,11 +87,11 @@ cargo install moq-cli # 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 + moq --client-connect https://relay.example.com/anon --broadcast my-stream import ts # Publish from FFmpeg ffmpeg -i input.mp4 -f mpegts - | \ - moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts + moq --client-connect https://relay.example.com/anon --broadcast my-stream import ts ``` ## Next Steps diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 8a0db2393c..a2e726787b 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -124,11 +124,11 @@ cargo install moq-cli ```bash # 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 + moq --client-connect https://relay.example.com/anon --broadcast my-stream import ts # Publish from FFmpeg ffmpeg -i input.mp4 -f mpegts - | \ - moq-cli publish --url https://relay.example.com/anon --broadcast my-stream ts + moq --client-connect https://relay.example.com/anon --broadcast my-stream import ts ``` [Learn more](/bin/cli) diff --git a/flake.lock b/flake.lock index e96909bebb..8f7cb64ca7 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1780099841, - "narHash": "sha256-EVZd2RsbpreRUDSi9rBwPY+ZxoyMaiEBbZxxhljbaS4=", + "lastModified": 1781825982, + "narHash": "sha256-SlXKwIRIhrOSAcTjCB3ftPLzJWZStQIPS7J1FlZPnKk=", "owner": "ipetkov", "repo": "crane", - "rev": "0532eb17955225173906d671fb36306bdeb1e2dc", + "rev": "469fd08d0bcf6926321fa973c6777fbc87785dd7", "type": "github" }, "original": { @@ -35,11 +35,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1780246643, - "narHash": "sha256-4T1KWX7xWGQMs9hNZ24IOY3aYOi8D6+5WtkNRBSttB8=", + "lastModified": 1782821651, + "narHash": "sha256-D5jO0ME1lA9bDHnd5kVawBwItG/N4oZr3FlXmMzLec8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3109eaae18e09d0b8aef23dc2579e7d94b8d4b4e", + "rev": "e52c192be9d7b2c4bd4aed326c8731b35f8bb75c", "type": "github" }, "original": { @@ -64,11 +64,11 @@ ] }, "locked": { - "lastModified": 1780284119, - "narHash": "sha256-y2wR4Mk6D/N1ID4FZa2oUMStCUxyIoRzmgOOpLzoWmo=", + "lastModified": 1782875958, + "narHash": "sha256-5eqDcnBjb1424HRQdnhuhNOBZguq1Z2tqSa2OMF/m2c=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "51390d0bfca0a68a8c337d215a4bbeddc2ca616e", + "rev": "13139aefa973f3d96c60c0fbab801de058ae25ca", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e981fa1349..aa778258b4 100644 --- a/flake.nix +++ b/flake.nix @@ -95,6 +95,7 @@ cargo-edit cargo-semver-checks cargo-deny + cargo-nextest # Browser/WASM bindings (rs/moq-wasm -> @moq/wasm via `just wasm`). # wasm-bindgen-cli must match the `wasm-bindgen` crate version (the # crate is pinned to nixpkgs' CLI version); bump both together. @@ -165,6 +166,12 @@ gzip ]; + # Developer workflow tooling not needed for builds: the GitHub CLI + # for opening/reviewing PRs from the dev shell. + devTools = with pkgs; [ + gh + ]; + # Linters / formatters required by `just ci`; `just check` and # `just fix` guard each tool with `command -v` so they skip # silently when the binary isn't on $PATH. @@ -176,6 +183,21 @@ nixfmt ]; + # Dependencies for building the OBS plugin (`just obs build`). + # Linux-only: nixpkgs marks obs-studio broken on Darwin, so macOS + # and Windows fetch libobs/Qt6 via the OBS buildspec instead (see + # cpp/obs/buildspec.json and doc/bin/obs.md). ffmpeg + cmake come from + # rustDeps. clang-tools/gersemi back `just obs check`. + obsDeps = + with pkgs; + lib.optionals (!stdenv.isDarwin) [ + obs-studio + qt6.qtbase + ninja + clang-tools + gersemi + ]; + # Apply our overlay to get the package definitions overlayPkgs = pkgs.extend self.overlays.default; in @@ -235,7 +257,8 @@ }; devShells.default = pkgs.mkShell { - packages = rustDeps ++ jsDeps ++ pyDeps ++ cdnDeps ++ packagingDeps ++ lintDeps; + packages = + rustDeps ++ jsDeps ++ pyDeps ++ cdnDeps ++ packagingDeps ++ lintDeps ++ obsDeps ++ devTools; # jemalloc's configure uses -O0 test builds, which conflict with # Nix's _FORTIFY_SOURCE hardening (requires -O). diff --git a/js/hang/package.json b/js/hang/package.json index 36e1cf21ce..d1eb4c385f 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -1,7 +1,7 @@ { "name": "@moq/hang", "type": "module", - "version": "0.2.11", + "version": "0.2.12", "description": "WebCodecs-based media format for MoQ", "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", diff --git a/js/json/package.json b/js/json/package.json index 431200307d..7bb045b96b 100644 --- a/js/json/package.json +++ b/js/json/package.json @@ -1,7 +1,7 @@ { "name": "@moq/json", "type": "module", - "version": "0.1.0", + "version": "0.1.1", "description": "Snapshot/delta JSON publishing over MoQ tracks using RFC 7396 JSON Merge Patch.", "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", diff --git a/js/moq-boy/package.json b/js/moq-boy/package.json index f200211da2..1834cb50b5 100644 --- a/js/moq-boy/package.json +++ b/js/moq-boy/package.json @@ -1,7 +1,7 @@ { "name": "@moq/boy", "type": "module", - "version": "0.2.10", + "version": "0.2.11", "jsr": false, "description": "MoQ Boy - Crowd-controlled Game Boy streaming via MoQ", "license": "(MIT OR Apache-2.0)", diff --git a/js/msf/package.json b/js/msf/package.json index c33f6729bf..35d5479248 100644 --- a/js/msf/package.json +++ b/js/msf/package.json @@ -1,7 +1,7 @@ { "name": "@moq/msf", "type": "module", - "version": "0.1.2", + "version": "0.1.3", "description": "MSF (MOQT Streaming Format) catalog types for MoQ", "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", diff --git a/js/net/package.json b/js/net/package.json index 768f79ee31..4794602e57 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -1,7 +1,7 @@ { "name": "@moq/net", "type": "module", - "version": "0.1.5", + "version": "0.1.6", "description": "The networking layer for Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization.", "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", @@ -17,7 +17,7 @@ "release": "bun ../common/release.ts" }, "dependencies": { - "@moq/qmux": "^0.1.1", + "@moq/qmux": "^0.1.3", "@moq/signals": "workspace:*", "async-mutex": "^0.5.0" }, diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 10454934b5..b4e47a2cf0 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -444,17 +444,18 @@ async function connectWebSocket(url: URL, delay: number, cancel: Promise): [Ietf.ALPN.DRAFT_15]: null, } as const satisfies Record; - // `withoutProtocol` also advertises bare `qmux-01`, `qmux-00`, and - // `webtransport` so we still interop with relays that only know a - // wire-format version (today's moq-relay only accepts bare `webtransport`). + // The default (`requireProtocol: false`) also advertises bare `qmux-01`, + // `qmux-00`, and `webtransport` so we still interop with relays that only + // know a wire-format version (today's moq-relay only accepts bare + // `webtransport`). const quic = new Session(url, { protocols: Object.keys(versions), versions, - withoutProtocol: true, }); // Wait for the WebSocket to connect, or for the cancel promise to resolve. - // Close the connection if we lost the race. + // `ready` rejects on a refused/failed connection, so a throw here is the caller's + // cue to retry; a lost cancel race instead resolves and we close the loser. const loaded = await Promise.race([quic.ready.then(() => true), cancel]); if (!loaded) { quic.close(); diff --git a/js/net/src/lite/track.ts b/js/net/src/lite/track.ts index 717a635654..5b57b7da9a 100644 --- a/js/net/src/lite/track.ts +++ b/js/net/src/lite/track.ts @@ -108,6 +108,9 @@ export class TrackInfo { async encode(w: Writer, version: Version): Promise { guardTrack(version); + // Reject a zero timescale on encode too, so an invalid TrackInfo fails fast on + // the sender rather than only at the peer's decoder. + if (this.timescale === 0) throw new Error("track timescale must be non-zero"); return Message.encode(w, (w) => this.#encode(w)); } diff --git a/js/publish/package.json b/js/publish/package.json index a752134512..f35dd09aeb 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -1,7 +1,7 @@ { "name": "@moq/publish", "type": "module", - "version": "0.2.15", + "version": "0.2.16", "jsr": false, "description": "Publish Media over QUIC broadcasts", "license": "(MIT OR Apache-2.0)", diff --git a/js/signals/package.json b/js/signals/package.json index 7cf5785890..e44e0d41e5 100644 --- a/js/signals/package.json +++ b/js/signals/package.json @@ -1,7 +1,7 @@ { "name": "@moq/signals", "type": "module", - "version": "0.1.9", + "version": "0.1.10", "description": "Reactive and safe signals", "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", diff --git a/js/watch/README.md b/js/watch/README.md index 4c0db7123a..7abcfb85b7 100644 --- a/js/watch/README.md +++ b/js/watch/README.md @@ -72,6 +72,7 @@ The simplest way to watch a stream: | `muted` | boolean | false | Mute audio | | `visible` | never, distance, or always | `0px` | When to download video (see below) | | `volume` | number | 0.5 | Audio volume (0-1) | +| `reload` | boolean | true | Wait for (re)announcement before subscribing. Defaults off for `mediaoverquic.com` relays until they support broadcast discovery. | The `visible` attribute controls when the video track is downloaded, based on the canvas position relative to the viewport: diff --git a/js/watch/package.json b/js/watch/package.json index c38d7d794d..44eea187f7 100644 --- a/js/watch/package.json +++ b/js/watch/package.json @@ -1,7 +1,7 @@ { "name": "@moq/watch", "type": "module", - "version": "0.2.17", + "version": "0.2.18", "jsr": false, "description": "Watch Media over QUIC broadcasts", "license": "(MIT OR Apache-2.0)", diff --git a/js/watch/src/audio/source.ts b/js/watch/src/audio/source.ts index fc8d34a278..366e323a40 100644 --- a/js/watch/src/audio/source.ts +++ b/js/watch/src/audio/source.ts @@ -175,5 +175,11 @@ function defaultAudioJitter(config: Catalog.AudioConfig): number | undefined { return Math.ceil((1024 / config.sampleRate) * 1000); } + if (config.codec === "mp3") { + // 1152 samples per frame for MPEG-1 Layer III; MPEG-2/2.5 use 576. + const samples = config.sampleRate >= 32000 ? 1152 : 576; + return Math.ceil((samples / config.sampleRate) * 1000); + } + return undefined; } diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 4d5964ea1a..ecb4c8b336 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -34,10 +34,12 @@ type BroadcastInput = { name: Getter; // Whether to reload the broadcast when it goes offline. - // Defaults to false; pass true to wait for an announcement before subscribing. + // Defaults to true; pass false to subscribe immediately without waiting for an announcement. reload: Getter; // Which catalog format to use. When `undefined` (the default), the format is + // auto-detected from the broadcast name extension (`.hang`, `.msf`), falling + // back to `"hang"` if the name has no recognized extension. Set to a // specific value to override auto-detection. `"hangz"` (the compressed // `catalog.json.z` track) is opt-in only and never auto-detected. catalogFormat: Getter; @@ -66,8 +68,9 @@ export class Broadcast { }; readonly output = readonlys(this.#output); - // All actively announced broadcast paths from the connection. - readonly #announced: Getter>; + // All actively announced broadcast paths from the connection. If omitted, reload skips the + // announcement gate and subscribes immediately. + readonly #announced?: Getter>; // Whether `name` is currently in the announced set (or skipping the check). // Derived in its own effect so that flaps for unrelated broadcasts don't @@ -81,12 +84,12 @@ export class Broadcast { connection: getter(props?.connection), name: getter(props?.name ?? Path.empty()), enabled: getter(props?.enabled ?? false), - reload: getter(props?.reload ?? false), + reload: getter(props?.reload ?? true), catalogFormat: getter(props?.catalogFormat), catalog: getter(props?.catalog), }; - this.#announced = props?.announced ?? new Signal(new Set()); + this.#announced = props?.announced; this.signals.run(this.#runAnnouncedNow.bind(this)); this.signals.run(this.#runBroadcast.bind(this)); @@ -100,12 +103,15 @@ export class Broadcast { return; } + if (!this.#announced) { + this.#announcedNow.set(true); + return; + } + // Cloudflare's relay does not yet support announcement subscriptions, - // so an announcement will never arrive. Fall back to subscribing - // immediately (reload=false behaviour) instead of waiting forever. + // so default to subscribing immediately instead of waiting forever. const conn = effect.get(this.input.connection); if (conn?.url.hostname.endsWith("mediaoverquic.com")) { - console.warn("Cloudflare relay does not support broadcast discovery yet; ignoring reload signal."); this.#announcedNow.set(true); return; } diff --git a/js/watch/src/element.ts b/js/watch/src/element.ts index 6e8b29c7f6..56f182b41b 100644 --- a/js/watch/src/element.ts +++ b/js/watch/src/element.ts @@ -36,6 +36,12 @@ function parseVisible(value: string | null): Video.Visible { return "0px"; } +function parseBoolean(value: string | null, defaultValue: boolean): boolean { + if (value === null) return defaultValue; + const normalized = value.trim().toLowerCase(); + return normalized !== "false" && normalized !== "0"; +} + // Close everything when this element is garbage collected. // This is primarily to avoid a console.warn that we didn't close() before GC. // There's no destructor for web components so this is the best we can do. @@ -70,7 +76,7 @@ export default class MoqWatch extends HTMLElement { // Broadcast configuration owned here and wired into `broadcast` as inputs. #name = new Signal(Moq.Path.empty()); - #reload = new Signal(false); + #reload = new Signal(true); #catalogFormat = new Signal(undefined); #catalog = new Signal(undefined); @@ -286,7 +292,7 @@ export default class MoqWatch extends HTMLElement { } else if (name === "visible") { this.controls.visible.set(parseVisible(newValue)); } else if (name === "reload") { - this.#reload.set(newValue !== null); + this.#reload.set(parseBoolean(newValue, true)); } else if (name === "latency") { // Sugar: collapse the floor and ceiling to a single value. this.latency = this.#parseBound(newValue); diff --git a/js/watch/src/ui/element.ts b/js/watch/src/ui/element.ts index a626ba4845..0b39aaecee 100644 --- a/js/watch/src/ui/element.ts +++ b/js/watch/src/ui/element.ts @@ -83,12 +83,71 @@ export default class MoqWatchUi extends HTMLElement { player.append(scrimTop, center, chrome, panel); DOM.render(effect, this.#root, player); - this.#wireChrome(effect, watch, state, player); + this.#fitMedia(effect, watch, player); + this.#wireChrome(effect, watch, state, player, chrome, panel); + } + + #fullscreen(player: HTMLElement): boolean { + const doc = document as Document & { webkitFullscreenElement?: Element | null }; + // Firefox reports shadow fullscreen on the shadow root. + const root = this.#root as ShadowRoot & { fullscreenElement?: Element | null }; + return ( + root.fullscreenElement === player || + document.fullscreenElement === player || + doc.webkitFullscreenElement === player || + player.classList.contains("player--pseudo-fullscreen") + ); + } + + #fitMedia(effect: Effect, watch: MoqWatch, player: HTMLElement) { + const apply = () => { + const media = watch.querySelector("canvas, video") as HTMLElement | null; + if (!media) return; + const fullscreen = this.#fullscreen(player); + + watch.style.width = "100%"; + watch.style.height = fullscreen ? "100%" : ""; + + media.style.width = "100%"; + media.style.height = fullscreen ? "100%" : "auto"; + media.style.maxWidth = "100%"; + media.style.maxHeight = "100%"; + media.style.objectFit = "contain"; + }; + + const observer = new MutationObserver(apply); + observer.observe(watch, { childList: true, subtree: true }); + + const playerObserver = new MutationObserver(apply); + playerObserver.observe(player, { attributes: true, attributeFilter: ["class"] }); + + effect.cleanup(() => observer.disconnect()); + effect.cleanup(() => playerObserver.disconnect()); + + effect.event(document, "fullscreenchange", apply); + effect.event(document, "webkitfullscreenchange", apply); + effect.event(this.#root, "fullscreenchange", apply); + apply(); } // Show the chrome on activity, auto-hide while playing once the pointer // settles. Stays pinned while paused or when the settings panel is open. - #wireChrome(effect: Effect, watch: MoqWatch, state: UiState, player: HTMLElement) { + #wireChrome( + effect: Effect, + watch: MoqWatch, + state: UiState, + player: HTMLElement, + chrome: HTMLElement, + panel: HTMLElement, + ) { + // Mobile devices don't support hover, so the controls remain visible in non-fullscreen mode. + // In fullscreen mode, tap the video to toggle the controls. + const touchUi = window.matchMedia("(pointer: coarse)").matches || window.matchMedia("(hover: none)").matches; + if (touchUi) { + this.#wireTouchChrome(effect, state, player, chrome, panel); + return; + } + // Bump on any pointer/focus activity to re-arm the auto-hide. const activity = new Signal(0); const bump = () => activity.update((n) => n + 1); @@ -118,6 +177,31 @@ export default class MoqWatchUi extends HTMLElement { player.classList.toggle("player--chrome", e.get(state.chrome)); }); } + + #wireTouchChrome(effect: Effect, state: UiState, player: HTMLElement, chrome: HTMLElement, panel: HTMLElement) { + state.chrome.set(true); + + const interactive = (event: Event) => + event.composedPath().some((target) => target === chrome || target === panel); + + effect.event(player, "pointerdown", (event) => { + if (interactive(event)) return; + if (this.#fullscreen(player)) { + state.chrome.update((shown) => !shown); + } else { + state.chrome.set(true); + } + }); + + const showChrome = () => state.chrome.set(true); + effect.event(document, "fullscreenchange", showChrome); + effect.event(document, "webkitfullscreenchange", showChrome); + effect.event(this.#root, "fullscreenchange", showChrome); + + effect.run((e) => { + player.classList.toggle("player--chrome", e.get(state.chrome)); + }); + } } customElements.define("moq-watch-ui", MoqWatchUi); diff --git a/js/watch/src/ui/styles/player.css b/js/watch/src/ui/styles/player.css index 7182e966a9..444faacd26 100644 --- a/js/watch/src/ui/styles/player.css +++ b/js/watch/src/ui/styles/player.css @@ -5,6 +5,9 @@ .player { position: relative; + display: flex; + align-items: center; + justify-content: center; width: 100%; overflow: hidden; border-radius: var(--spacing-8); @@ -16,6 +19,7 @@ .player ::slotted(moq-watch) { display: block; width: 100%; + line-height: 0; } /* Hide the cursor along with the chrome while playing. */ @@ -47,10 +51,19 @@ z-index: 2147483647; } +/* Avoid mobile browser bars overlapping pseudo fullscreen controls. */ +@supports (height: 100dvh) { + .player--pseudo-fullscreen { + height: 100dvh; + } +} + .player:fullscreen ::slotted(moq-watch), /* stylelint-disable-next-line selector-no-vendor-prefix -- Safari/iPadOS fallback */ .player:-webkit-full-screen ::slotted(moq-watch), .player--pseudo-fullscreen ::slotted(moq-watch) { + width: 100%; + height: 100%; max-height: 100%; } diff --git a/justfile b/justfile index bdcb4eb8a9..3404dce643 100644 --- a/justfile +++ b/justfile @@ -10,6 +10,9 @@ mod kt mod swift mod go +# OBS Studio plugin (C++). See doc/bin/obs.md. +mod obs 'cpp/obs' + # Unit tests per language (`just test`). mod test diff --git a/nix/overlay.nix b/nix/overlay.nix index 8f8e34371e..8c514b0a5d 100644 --- a/nix/overlay.nix +++ b/nix/overlay.nix @@ -57,6 +57,8 @@ let moqCliArgs = crateInfo ../rs/moq-cli/Cargo.toml // { src = craneLib.cleanCargoSource ../.; cargoExtraArgs = "-p moq-cli"; + # The crate is `moq-cli`, but its `[[bin]]` ships as `moq`. + meta.mainProgram = "moq"; }; moqTokenCliArgs = crateInfo ../rs/moq-token-cli/Cargo.toml // { diff --git a/rs/CLAUDE.md b/rs/CLAUDE.md index a4737d0dce..2053fea436 100644 --- a/rs/CLAUDE.md +++ b/rs/CLAUDE.md @@ -31,9 +31,10 @@ Layered roughly transport -> container/format -> media -> apps/bindings. **Apps / binaries** - `moq-relay` (lib+bin): clusterable, media-agnostic relay. axum HTTP API, JWT auth, WebSocket fallback, clustering. Config/TOML merge pattern lives here (see below). -- `moq-rtc` (lib+bin): WebRTC (WHIP/WHEP) gateway. Bridges browser WebRTC ingest/playback to MoQ broadcasts (ICE/DTLS, A/V sync, NACK). -- `moq-cli` (lib+bin, `moq`): serve/accept/publish/subscribe; stdin/stdout media piping. -- `moq-rtc` (lib+bin): WebRTC (WHIP/WHEP) gateway. Bridges browser WebRTC ingest/playback to MoQ broadcasts (str0m ICE/DTLS, A/V sync, NACK). Embeddable lib (`default-features = false`) + standalone binary (`server` feature). +- `moq-cli` (bin, `moq`): the unified media router (`moq `); stdin/stdout media piping. The CLI surface for the gateway library crates below lives here. +- `moq-rtc` (lib): WebRTC (WHIP/WHEP) gateway. Bridges browser WebRTC ingest/playback to MoQ broadcasts (str0m ICE/DTLS, A/V sync, NACK). Embeddable axum routers / `Client`; the CLI surface lives in `moq-cli`. +- `moq-rtmp` (lib): RTMP / enhanced-RTMP gateway (ingest + egress, `rml_rtmp`, FLV via `moq-mux`). RTMPS (rustls + tokio-rustls) is the optional `tls` feature. +- `moq-srt` (lib): bidirectional SRT gateway (MPEG-TS via `srt-tokio` + `moq-mux`). - `moq-bench` (bin): relay load generator. `JoinSet`-spawned staggered connections, rand sampling. - `moq-boy` (bin): crowd-controlled Game Boy emulator publisher (blocking emulator thread + async monitor tasks). - `moq-token` (lib) / `moq-token-cli` (bin): JWT auth. `Claims`, `Algorithm`, `KeyType` (EC/RSA/OCT/OKP), JWKS. CLI does generate/sign/verify. diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index 2ae7247aeb..0bec41b7ec 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.3](https://github.com/moq-dev/moq/compare/hang-v0.19.2...hang-v0.19.3) - 2026-06-30 + +### Added + +- *(hang)* compressed catalog track (catalog.json.z) ([#1904](https://github.com/moq-dev/moq/pull/1904)) + +### Other + +- [codex] Route HLS CLI import through moq-hls ([#1939](https://github.com/moq-dev/moq/pull/1939)) +- Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) ([#1918](https://github.com/moq-dev/moq/pull/1918)) + ## [0.19.2](https://github.com/moq-dev/moq/compare/hang-v0.19.1...hang-v0.19.2) - 2026-06-19 ### Fixed diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index e5f653dded..2dfcc75a4f 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.19.2" +version = "0.19.3" edition = "2024" rust-version.workspace = true diff --git a/rs/hang/src/catalog/audio/codec.rs b/rs/hang/src/catalog/audio/codec.rs index fa901e7f82..8009ecbe10 100644 --- a/rs/hang/src/catalog/audio/codec.rs +++ b/rs/hang/src/catalog/audio/codec.rs @@ -15,6 +15,23 @@ pub enum AudioCodec { #[display("opus")] Opus, + /// FLAC, the Free Lossless Audio Codec (RFC 9639). The decoder + /// initialization data (the `fLaC` stream marker plus the STREAMINFO + /// metadata block) travels out of band in [`AudioConfig::description`]. Both + /// the codec string and the description match the WebCodecs FLAC + /// registration, so browsers decode it directly. + /// + /// [`AudioConfig::description`]: super::AudioConfig::description + #[display("flac")] + Flac, + + /// MPEG-1/2 Audio Layer III (MP3). Configuration is carried in band in each + /// frame header, so there is no out-of-band description. Browsers decode it + /// via WebCodecs (`AudioDecoder` codec string `"mp3"`), so unlike the legacy + /// codecs below it gets its own [`AudioCodecKind`]. + #[display("mp3")] + Mp3, + /// MPEG-1/2 Audio Layer II. Legacy broadcast codec, carried verbatim by the /// MPEG-TS bridge for TS gear. WebCodecs cannot decode it, so browsers should /// skip this rendition. Do not use it for new content. @@ -45,6 +62,8 @@ pub enum AudioCodec { pub enum AudioCodecKind { AAC, Opus, + Flac, + Mp3, Unknown, } @@ -54,6 +73,8 @@ impl AudioCodec { match self { Self::AAC(_) => AudioCodecKind::AAC, Self::Opus => AudioCodecKind::Opus, + Self::Flac => AudioCodecKind::Flac, + Self::Mp3 => AudioCodecKind::Mp3, // Legacy TS-bridge codecs aren't WebCodecs-decodable, so they share the // coarse Unknown family for tag-only matching. Self::Mp2 | Self::Ac3 | Self::Ec3 => AudioCodecKind::Unknown, @@ -70,6 +91,10 @@ impl FromStr for AudioCodec { return AAC::from_str(s).map(Into::into); } else if s == "opus" { return Ok(Self::Opus); + } else if s == "flac" { + return Ok(Self::Flac); + } else if s == "mp3" { + return Ok(Self::Mp3); } else if s == "mp2" { return Ok(Self::Mp2); } else if s == "ac-3" { @@ -81,3 +106,16 @@ impl FromStr for AudioCodec { Ok(Self::Unknown(s.to_string())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flac_roundtrip() { + let codec = AudioCodec::from_str("flac").unwrap(); + assert_eq!(codec, AudioCodec::Flac); + assert_eq!(codec.to_string(), "flac"); + assert_eq!(codec.kind(), AudioCodecKind::Flac); + } +} diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index a5bb28ab7a..ddc9196014 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -33,6 +33,12 @@ impl Catalog { /// The default name for the catalog track. pub const DEFAULT_NAME: &str = "catalog.json"; + /// The track name for the DEFLATE-compressed catalog: the `.z` sibling of [`DEFAULT_NAME`](Self::DEFAULT_NAME). + /// + /// Carries the identical catalog JSON, compressed per group (see `moq-json`). A publisher serves + /// both tracks; a consumer reads whichever it prefers. + pub const COMPRESSED_NAME: &str = "catalog.json.z"; + /// Parse a catalog from a string. #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Result { diff --git a/rs/justfile b/rs/justfile index a18edddc7d..ecc3a68bb8 100644 --- a/rs/justfile +++ b/rs/justfile @@ -42,19 +42,23 @@ ci FILES="": 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 + # Compiles. The all-features clippy / doc / test run as plain cargo (flake.nix + # `checks` is unwired). Each full-workspace compile below is expensive, so we + # run the minimum set that still covers every feature edge: + # - clippy --all-targets --all-features is the gate and a superset of a plain + # `cargo check --all-targets` (default features), so that redundant check + # pass is intentionally omitted -- one fewer full workspace compile. + # - --no-default-features stays: it's the only pass that exercises code behind + # `#[cfg(not(feature = ...))]`, which the all-features run never compiles. + # (Local devs still get the full cargo loop via `just rs check`.) cargo check --workspace --no-default-features 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 + # nextest compiles + runs the test binaries with real parallelism (faster than + # `cargo test`'s serial harness). It does not build or run + # doctests, so the `/// ```` examples are no longer compile-checked in CI -- an + # accepted trade for speed (they still render in `cargo doc` output). + cargo nextest run --workspace --all-targets --all-features # Auto-fix clippy/format/shear/sort. fix: diff --git a/rs/kio/CHANGELOG.md b/rs/kio/CHANGELOG.md index 2808621577..a1c008b538 100644 --- a/rs/kio/CHANGELOG.md +++ b/rs/kio/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.1](https://github.com/moq-dev/moq/compare/kio-v0.4.0...kio-v0.4.1) - 2026-06-30 + +### Added + +- *(moq-net)* add OriginProducer::dynamic + infallible OriginConsumer::request_broadcast ([#1913](https://github.com/moq-dev/moq/pull/1913)) + ## [0.4.0](https://github.com/moq-dev/moq/compare/kio-v0.3.0...kio-v0.4.0) - 2026-06-16 ### Fixed diff --git a/rs/kio/Cargo.toml b/rs/kio/Cargo.toml index 531a5fc1f8..072cab6fec 100644 --- a/rs/kio/Cargo.toml +++ b/rs/kio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.0" +version = "0.4.1" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index a19bad73a4..aa1cd19d89 100644 --- a/rs/libmoq/CHANGELOG.md +++ b/rs/libmoq/CHANGELOG.md @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the video counterpart to `moq_consume_audio_raw`. Decoding happens inside libmoq (VideoToolbox / openh264), so consumers no longer need ffmpeg. +## [0.3.9](https://github.com/moq-dev/moq/compare/libmoq-v0.3.8...libmoq-v0.3.9) - 2026-06-30 + +### Other + +- API cleanup before the semver bump ([#1941](https://github.com/moq-dev/moq/pull/1941)) +- Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) ([#1918](https://github.com/moq-dev/moq/pull/1918)) + ## [0.3.8](https://github.com/moq-dev/moq/compare/libmoq-v0.3.7...libmoq-v0.3.8) - 2026-06-23 ### Added diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index 89e1b3e2ed..35c90278a8 100644 --- a/rs/libmoq/Cargo.toml +++ b/rs/libmoq/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.8" +version = "0.3.9" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index 04c3bb1e08..4b556116c7 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -30,7 +30,8 @@ pub struct Publish { impl Publish { pub fn create(&mut self) -> Result { let mut broadcast = moq_net::BroadcastInfo::new().produce(); - let catalog = moq_mux::catalog::Producer::new_extra(&mut broadcast)?; + let catalog = + moq_mux::catalog::Producer::with_catalog(&mut broadcast, moq_mux::catalog::hang::Catalog::default())?; let id = self.broadcasts.insert((broadcast, catalog))?; Ok(id) @@ -146,7 +147,7 @@ impl Publish { /// The catalog is republished automatically. pub fn catalog_section_set(&mut self, broadcast: Id, name: &str, value: serde_json::Value) -> Result<(), Error> { let (_, catalog) = self.broadcasts.get_mut(broadcast).ok_or(Error::BroadcastNotFound)?; - catalog.set_section(name.to_string(), value)?; + catalog.lock().set_section(name.to_string(), value)?; Ok(()) } @@ -155,7 +156,7 @@ impl Publish { /// A no-op if no section with that name exists. Republishes the catalog if it did. pub fn catalog_section_remove(&mut self, broadcast: Id, name: &str) -> Result<(), Error> { let (_, catalog) = self.broadcasts.get_mut(broadcast).ok_or(Error::BroadcastNotFound)?; - catalog.remove_section(name); + catalog.lock().remove_section(name); Ok(()) } diff --git a/rs/moq-audio/CHANGELOG.md b/rs/moq-audio/CHANGELOG.md index 251b8a2db1..48bfcc17ad 100644 --- a/rs/moq-audio/CHANGELOG.md +++ b/rs/moq-audio/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.6](https://github.com/moq-dev/moq/compare/moq-audio-v0.0.5...moq-audio-v0.0.6) - 2026-06-30 + +### Other + +- API cleanup before the semver bump ([#1941](https://github.com/moq-dev/moq/pull/1941)) +- Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) ([#1918](https://github.com/moq-dev/moq/pull/1918)) + ## [0.0.5](https://github.com/moq-dev/moq/compare/moq-audio-v0.0.4...moq-audio-v0.0.5) - 2026-06-23 ### Other diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 58dc378366..831ad2d543 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.5" +version = "0.0.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index b00aba79f3..18971624cb 100644 --- a/rs/moq-boy/CHANGELOG.md +++ b/rs/moq-boy/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.22](https://github.com/moq-dev/moq/compare/moq-boy-v0.2.21...moq-boy-v0.2.22) - 2026-06-30 + +### Added + +- *(json)* group-scoped DEFLATE compression with browser support ([#1897](https://github.com/moq-dev/moq/pull/1897)) + +### Other + +- Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) ([#1918](https://github.com/moq-dev/moq/pull/1918)) + ## [0.2.21](https://github.com/moq-dev/moq/compare/moq-boy-v0.2.20...moq-boy-v0.2.21) - 2026-06-23 ### Other diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 2ef006e674..ee38583534 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" keywords = ["moq", "gameboy", "streaming", "emulator", "live"] categories = ["multimedia::video", "emulators", "network-programming"] -version = "0.2.21" +version = "0.2.22" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 512a4819ad..24c931f653 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.35](https://github.com/moq-dev/moq/compare/moq-cli-v0.7.34...moq-cli-v0.7.35) - 2026-06-30 + +### Added + +- *(moq-srt)* bidirectional SRT/MPEG-TS gateway (+ timestamped ts::Export) ([#1915](https://github.com/moq-dev/moq/pull/1915)) +- *(hang)* compressed catalog track (catalog.json.z) ([#1904](https://github.com/moq-dev/moq/pull/1904)) + +### Other + +- *(deps)* bump the cargo group across 1 directory with 18 updates ([#1942](https://github.com/moq-dev/moq/pull/1942)) +- [codex] Route HLS CLI import through moq-hls ([#1939](https://github.com/moq-dev/moq/pull/1939)) +- Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) ([#1918](https://github.com/moq-dev/moq/pull/1918)) + ## [0.7.34](https://github.com/moq-dev/moq/compare/moq-cli-v0.7.33...moq-cli-v0.7.34) - 2026-06-23 ### Added diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 9ace9f7c19..7ab9b2e7c2 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -5,13 +5,18 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.7.34" +version = "0.8.0" edition = "2024" rust-version.workspace = true keywords = ["quic", "http3", "webtransport", "media", "live"] categories = ["multimedia", "network-programming", "web-programming"] +# The package is `moq-cli`, but the binary it ships is `moq`. +[[bin]] +name = "moq" +path = "src/main.rs" + [features] default = ["iroh", "noq", "websocket", "nvenc", "vaapi"] iroh = ["moq-native/iroh"] @@ -42,14 +47,17 @@ clap = { version = "4", features = ["derive"] } hang = { workspace = true } humantime = "2.3" moq-audio = { workspace = true, optional = true } -# `server` enables the HTTP egress server for `moq hls export`; the importer is always available. +# `server` enables the HTTP egress server for `moq export hls`; the importer is always available. moq-hls = { workspace = true, features = ["server"] } moq-mux = { workspace = true } moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"] } +moq-rtc = { workspace = true } +moq-rtmp = { workspace = true } +moq-srt = { workspace = true } moq-video = { workspace = true, optional = true } rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false } tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.7", features = ["cors", "fs"] } +tower-http = { version = "0.7", features = ["cors"] } tracing = "0.1" url = "2" diff --git a/rs/moq-cli/README.md b/rs/moq-cli/README.md index ad61529359..6e413e1726 100644 --- a/rs/moq-cli/README.md +++ b/rs/moq-cli/README.md @@ -19,40 +19,40 @@ Multi-arch images (`linux/amd64` and `linux/arm64`) are published to [Docker Hub ## Usage -`moq-cli` reads media from stdin (or writes media to stdout) and exchanges it with a MoQ relay. Pick a subcommand based on whether you want to publish or subscribe, and whether your relay is local or remote. +`moq-cli` routes one endpoint onto a shared MoQ Origin: `moq `. The MoQ side (before the verb) is either `--client-connect ` (dial a relay) or `--server-bind ` (self-host). `import` moves media into MoQ, `export` moves it out. The endpoint is a container format (`fmp4`, `ts`, `flv`, ... read from stdin / written to stdout), or a gateway (`hls`, `rtmp`, `srt`, `rtc`). ### Publish to a remote relay ```bash ffmpeg -i input.mp4 -f mp4 -movflags cmaf - | \ - moq-cli publish --url https://relay.example.com --broadcast my-stream fmp4 + moq --client-connect https://relay.example.com --broadcast my-stream.hang import fmp4 ``` ### Subscribe from a remote relay ```bash -moq-cli subscribe --url https://relay.example.com --broadcast my-stream --format fmp4 | \ +moq --client-connect https://relay.example.com --broadcast my-stream.hang export fmp4 | \ ffplay - ``` -### Self-host: publish into a local relay (`serve`) +### Self-host: publish into a local relay -Runs a relay and publishes a single broadcast read from stdin into it. Useful for local testing without a separate relay process. +Hosts a MoQ server and publishes a single broadcast read from stdin into it. Useful for local testing without a separate relay process. ```bash ffmpeg -i input.mp4 -f mp4 -movflags cmaf - | \ - moq-cli serve --broadcast my-stream fmp4 + moq --server-bind '[::]:4443' --tls-generate localhost --broadcast my-stream.hang import fmp4 ``` -### Self-host: subscribe to an inbound broadcast (`accept`) +### Self-host: subscribe to an inbound broadcast -Runs a relay and writes the first incoming broadcast's media to stdout. The inverse of `serve`. +Hosts a MoQ server and writes an incoming broadcast's media to stdout. The inverse of the above. ```bash -moq-cli accept --broadcast my-stream --format fmp4 | ffplay - +moq --server-bind '[::]:4443' --tls-generate localhost --broadcast my-stream.hang export fmp4 | ffplay - ``` -### Input formats (`publish` / `serve`) +### Import formats - `avc3` raw H.264 Annex-B from stdin - `fmp4` fragmented MP4 from stdin diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs new file mode 100644 index 0000000000..07af6aeaf7 --- /dev/null +++ b/rs/moq-cli/src/args.rs @@ -0,0 +1,220 @@ +//! The unified moq-cli argument surface. +//! +//! Grammar: `moq [endpoint opts]`. +//! +//! - The MoQ side (`--client-connect` / `--server-bind`, both optional, at least +//! one) attaches the shared Origin to the MoQ network, and comes before the +//! verb. Both may be given: dial a relay *and* accept incoming sessions. +//! - `import` routes media INTO MoQ from one source; `export` routes it OUT to +//! one sink. The verb fixes the data direction (and thus, for the +//! bidirectional gateways, whether `--connect`/`--listen` push or pull). +//! - The endpoint is one subcommand: a container format (`ts`, `fmp4`, ... read +//! from stdin on import, written to stdout on export) or a gateway (`hls`, +//! `rtmp`, `srt`, `rtc`). Exactly one per invocation, so "which endpoint" is +//! unambiguous and there's no silently-ignored flag. + +use std::time::Duration; + +use clap::{ArgGroup, Args, Parser, Subcommand}; +use url::Url; + +use crate::publish::PublishFormat; +use crate::subscribe::{CatalogFormatArg, SubscribeFormat}; + +/// moq-cli: a media router that wires one endpoint onto a shared MoQ Origin. +#[derive(Parser, Clone)] +#[command(name = "moq", version = env!("VERSION"))] +pub struct Cli { + /// Logging configuration. + #[command(flatten)] + pub log: moq_native::Log, + + /// The MoQ attachment, shared by both directions. + #[command(flatten)] + pub moq: MoqSide, + + /// The routing direction and endpoint. + #[command(subcommand)] + pub direction: Direction, +} + +/// The MoQ attachment. At least one of `--client-connect` / `--server-bind`; +/// both may be given at once. +#[derive(Args, Clone)] +#[command(group = ArgGroup::new("moq").required(true).multiple(true).args(["client-connect", "server-bind"]))] +pub struct MoqSide { + /// Dial a MoQ relay/server over WebTransport. + /// + /// The URL path is the relay auth path (e.g. `/anon` for a public relay); the + /// broadcast rides on top of it (via `--broadcast` or the endpoint). `?jwt=` + /// supplies a token. `http://` first fetches `/certificate.sha256` for the + /// (insecure) self-signed fingerprint; `https://` connects directly. + #[arg( + id = "client-connect", + long = "client-connect", + env = "MOQ_CLIENT_CONNECT", + help_heading = "MoQ" + )] + pub client_connect: Option, + + /// The broadcast name. Optional for the point endpoints (stdin/stdout, HLS + /// import, and the `--connect` dials), which default to the root broadcast at + /// the connection path; required by the `--listen` endpoints and `hls export`, + /// which bridge one named broadcast. + #[arg(long, alias = "name", help_heading = "MoQ")] + pub broadcast: Option, + + /// MoQ client transport config (`--client-bind`, `--client-tls-*`, ...). + #[command(flatten)] + pub client: moq_native::ClientConfig, + + /// MoQ server transport config (`--server-bind`, `--server-tls-*`, `--tls-*`). + #[command(flatten)] + pub server: moq_native::ServerConfig, + + /// Iroh transport config (`--iroh-*`), used by both the client and server. + #[cfg(feature = "iroh")] + #[command(flatten)] + pub iroh: moq_native::iroh::EndpointConfig, +} + +/// The data direction: the pivot between the MoQ side and the endpoint. +#[derive(Subcommand, Clone)] +pub enum Direction { + /// Route media INTO MoQ from one source. + Import(Import), + /// Route media OUT OF MoQ to one sink. + Export(Export), +} + +// ------------------------------------------------------------------ import + +/// import = one source -> MoQ. +#[derive(Args, Clone)] +pub struct Import { + /// The single source feeding the Origin. + #[command(subcommand)] + pub source: ImportSource, +} + +/// The single source feeding the Origin on an import. The container formats read +/// from stdin; the gateways bridge another protocol. +#[derive(Subcommand, Clone)] +pub enum ImportSource { + /// Raw H.264 Annex-B from stdin. + Avc3, + /// Fragmented MP4 / CMAF from stdin. + Fmp4, + /// MPEG-TS from stdin. + Ts, + /// FLV / RTMP container from stdin. + Flv, + /// Pull a remote HLS / LL-HLS playlist (http/https URL or local file) into MoQ. + Hls(crate::hls::ImportArgs), + /// RTMP: pull a remote play (`--connect`) or accept incoming publishes (`--listen`). + Rtmp(crate::rtmp::Args), + /// SRT: pull a remote stream (`--connect`) or accept incoming publishes (`--listen`). + Srt(crate::srt::Args), + /// WebRTC: WHEP client pulling a remote (`--connect`) or WHIP server accepting publishes (`--listen`). + Rtc(crate::rtc::Args), + /// Capture the camera/microphone (or screen) and encode natively. + #[cfg(feature = "capture")] + Capture(crate::publish::CaptureArgs), +} + +impl ImportSource { + /// The stdin container format, when this source is one of the container formats. + pub fn stdin_format(&self) -> Option { + Some(match self { + Self::Avc3 => PublishFormat::Avc3, + Self::Fmp4 => PublishFormat::Fmp4, + Self::Ts => PublishFormat::Ts, + Self::Flv => PublishFormat::Flv, + _ => return None, + }) + } +} + +// ------------------------------------------------------------------ export + +/// export = MoQ -> one sink. +#[derive(Args, Clone)] +pub struct Export { + /// Catalog format to read for track discovery (default: detect from the broadcast suffix). + #[arg(long = "catalog-format")] + pub catalog_format: Option, + + /// Rendition selection (`--video-name`, `--video-codec`, `--audio-name`, `--audio-codec`). + #[command(flatten)] + pub select: crate::subscribe::SelectArgs, + + /// The single sink draining the Origin. + #[command(subcommand)] + pub sink: ExportSink, +} + +/// The single sink draining the Origin on an export. The container formats write +/// to stdout; the gateways bridge another protocol. +#[derive(Subcommand, Clone)] +pub enum ExportSink { + /// Fragmented MP4 / CMAF to stdout. + Fmp4(Fragmented), + /// Matroska / WebM to stdout. + Mkv(Fragmented), + /// MPEG-TS to stdout. + Ts(Container), + /// FLV / RTMP container to stdout. + Flv(Container), + /// H.264 Annex-B elementary stream to stdout. + H264(Container), + /// H.265 Annex-B elementary stream to stdout. + H265(Container), + /// Serve HLS / LL-HLS over HTTP. + Hls(crate::hls::ExportArgs), + /// RTMP: push to a remote (`--connect`) or serve plays (`--listen`). + Rtmp(crate::rtmp::ExportArgs), + /// SRT: push to a remote (`--connect`) or serve requests (`--listen`). + Srt(crate::srt::Args), + /// WebRTC: WHIP client pushing to a remote (`--connect`) or WHEP server serving plays (`--listen`). + Rtc(crate::rtc::Args), +} + +impl ExportSink { + /// The stdout container format plus its latency and fragment cap, when this + /// sink writes to stdout (the container formats). The fragment cap is + /// fmp4/mkv-only. + pub fn stdout(&self) -> Option<(SubscribeFormat, Duration, Option)> { + Some(match self { + Self::Fmp4(args) => ( + SubscribeFormat::Fmp4, + args.container.latency_max, + args.fragment_duration, + ), + Self::Mkv(args) => (SubscribeFormat::Mkv, args.container.latency_max, args.fragment_duration), + Self::Ts(args) => (SubscribeFormat::Ts, args.latency_max, None), + Self::Flv(args) => (SubscribeFormat::Flv, args.latency_max, None), + Self::H264(args) => (SubscribeFormat::H264, args.latency_max, None), + Self::H265(args) => (SubscribeFormat::H265, args.latency_max, None), + _ => return None, + }) + } +} + +/// Options shared by every stdout container sink. +#[derive(Args, Clone)] +pub struct Container { + /// Maximum latency before skipping a stalled group (e.g. `500ms`, `1s`). + #[arg(long = "latency-max", default_value = "500ms", value_parser = humantime::parse_duration)] + pub latency_max: Duration, +} + +/// The fmp4 / mkv stdout containers: [`Container`] plus a fragment cap. +#[derive(Args, Clone)] +pub struct Fragmented { + #[command(flatten)] + pub container: Container, + + /// Cap the output fragment/cluster duration (e.g. `2s`). Default: one GOP. + #[arg(long, value_parser = humantime::parse_duration)] + pub fragment_duration: Option, +} diff --git a/rs/moq-cli/src/client.rs b/rs/moq-cli/src/client.rs deleted file mode 100644 index 5269ab1c9e..0000000000 --- a/rs/moq-cli/src/client.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::Publish; - -use anyhow::Context; -use hang::moq_net; -use url::Url; - -pub async fn run_client(client: moq_native::Client, url: Url, name: String, publish: Publish) -> anyhow::Result<()> { - // Create an origin producer to publish to the broadcast. - let origin = moq_net::Origin::random().produce(); - let _publish = origin - .publish_broadcast(&name, publish.consume()) - .context("failed to publish broadcast")?; - - tracing::info!(%url, %name, "connecting"); - - let reconnect = client.with_publisher(&origin).reconnect(url); - - #[cfg(unix)] - // Notify systemd that we're ready. - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); - - tokio::select! { - res = publish.run() => res, - res = reconnect.closed() => Ok(res?), - _ = tokio::signal::ctrl_c() => Ok(()), - } -} diff --git a/rs/moq-cli/src/hls.rs b/rs/moq-cli/src/hls.rs new file mode 100644 index 0000000000..73d900069d --- /dev/null +++ b/rs/moq-cli/src/hls.rs @@ -0,0 +1,97 @@ +//! HLS / LL-HLS endpoints: pull a remote playlist into MoQ (import), or serve +//! HLS over HTTP from MoQ broadcasts (export). + +use std::net::SocketAddr; +use std::time::Duration; + +use anyhow::Context; +use axum::http::Method; +use hang::moq_net; +use hang::moq_net::AsPath; + +use crate::moq::notify_ready; + +/// HLS import (pull a remote playlist) args. +#[derive(clap::Args, Clone)] +pub struct ImportArgs { + /// Playlist URL (http/https) or local file path. + pub playlist: String, +} + +/// HLS export (serve over HTTP) args. +#[derive(clap::Args, Clone)] +pub struct ExportArgs { + /// HTTP listener for the HLS endpoints. + #[arg(long, default_value = "[::]:8089")] + pub listen: SocketAddr, + + /// TLS certificates, keys, self-signed generation, and optional mTLS roots. + #[command(flatten)] + pub tls: moq_native::tls::Server, + + /// LL-HLS part target duration. + #[arg(long, default_value = "500ms", value_parser = humantime::parse_duration)] + pub part_target: Duration, + + /// Minimum media kept in each rendition's sliding window. + #[arg(long, default_value = "16s", value_parser = humantime::parse_duration)] + pub window: Duration, + + /// Maximum latency before skipping a stalled group. Generous by default so + /// live GOPs aren't skipped while segments build. + #[arg(long = "latency-max", default_value = "10s", value_parser = humantime::parse_duration)] + pub latency_max: Duration, + + /// Browser CORS policy for the HLS listener. + #[command(flatten)] + pub cors: crate::web::Cors, +} + +/// Pull a remote HLS/LL-HLS playlist (URL or file path) into the Origin under `name`. +pub async fn import(origin: &moq_net::OriginProducer, name: String, playlist: String) -> anyhow::Result<()> { + let mut producer = moq_net::BroadcastInfo::new().produce(); + // Hold the RAII announcement for the lifetime of the import. + let _announce = origin + .publish_broadcast(&name, producer.consume()) + .context("failed to publish broadcast")?; + + let catalog = moq_mux::catalog::Producer::new(&mut producer)?; + let mut importer = moq_hls::import::Import::new(producer, catalog, moq_hls::import::Config::new(playlist))?; + + tracing::info!(%name, "importing HLS"); + + importer.init().await?; + notify_ready(); + Ok(importer.run().await?) +} + +/// Serve HLS/LL-HLS over HTTP for the single broadcast `name` (reached at +/// `//master.m3u8`); other broadcasts in the Origin are not served. +pub async fn export(origin: moq_net::OriginConsumer, args: ExportArgs, name: String) -> anyhow::Result<()> { + let scoped = origin + .scope(&[name.as_path()]) + .with_context(|| format!("failed to scope origin to broadcast `{name}`"))?; + + let config = moq_hls::export::Config { + part_target: args.part_target, + window: args.window, + latency: args.latency_max, + ..Default::default() + }; + let server = moq_hls::Server::new(scoped, config); + let app = server.router().layer(args.cors.layer([Method::GET])?); + + let tls = if args.tls.cert.is_empty() && args.tls.generate.is_empty() { + None + } else { + let alpn = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Some(args.tls.server_config(alpn)?) + }; + + let listener = moq_native::bind::tcp(args.listen)?; + + tracing::info!(listen = %args.listen, "serving HLS"); + notify_ready(); + + crate::web::serve(listener, app, tls).await +} diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index c372f73288..11252b0ddd 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -1,163 +1,50 @@ -mod client; +//! moq-cli: a media router that wires one endpoint onto a shared MoQ Origin. +//! +//! The binary is `moq`. See [`args`] for the `import`/`export` command grammar; +//! this module orchestrates the shared Origin and spawns the MoQ side plus the +//! selected endpoint. + +mod args; +mod hls; +mod moq; mod publish; -mod server; +mod rtc; +mod rtmp; +mod srt; mod subscribe; mod web; -use client::*; +use args::{Cli, Direction, Export, ExportSink, Import, ImportSource, MoqSide}; use hang::moq_net; -use publish::*; -use server::*; -use subscribe::*; -use web::*; +use publish::Publish; +use subscribe::{Subscribe, SubscribeArgs}; -use clap::{Parser, Subcommand}; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use tower_http::cors::{Any, CorsLayer}; -use url::Url; +use anyhow::Context; +use clap::Parser; +use tokio::task::JoinSet; -#[derive(Parser, Clone)] -#[command(version = env!("VERSION"))] -pub struct Cli { - #[command(flatten)] - log: moq_native::Log, - - /// Iroh configuration - #[command(flatten)] +/// Everything needed to build MoQ clients/servers, encapsulating the optional +/// iroh endpoint so the rest of the code is feature-agnostic. +#[derive(Clone)] +struct Net { #[cfg(feature = "iroh")] - iroh: moq_native::iroh::EndpointConfig, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Clone)] -pub enum Command { - /// Run a relay and publish a single broadcast read from stdin into it. - Serve { - #[command(flatten)] - config: moq_native::ServerConfig, - - /// The name of the broadcast to serve. - #[arg(long, alias = "name")] - broadcast: String, - - /// Optionally serve static files from the given directory. - #[arg(long)] - dir: Option, - - /// The format of the input media. - #[command(subcommand)] - format: PublishFormat, - }, - /// Run a relay and write the first incoming broadcast's media to stdout. - Accept { - #[command(flatten)] - config: moq_native::ServerConfig, - - /// The name of the broadcast to accept. - #[arg(long, alias = "name")] - broadcast: String, - - /// Optionally serve static files from the given directory. - #[arg(long)] - dir: Option, - - #[command(flatten)] - args: SubscribeArgs, - }, - /// Publish a broadcast read from stdin to a remote relay. - Publish { - /// The MoQ client configuration. - #[command(flatten)] - config: moq_native::ClientConfig, - - /// The URL of the MoQ server. - /// - /// The URL must start with `https://` or `http://`. - /// - If `http` is used, a HTTP fetch to "/certificate.sha256" is first made to get the TLS certificiate fingerprint (insecure). - /// - If `https` is used, then A WebTransport connection is made via QUIC to the provided host/port. - /// - /// The `?jwt=` query parameter is used to provide a JWT token from moq-token-cli. - /// Otherwise, the public path (if any) is used instead. - /// - /// The path currently must be `/` or you'll get an error on connect. - #[arg(long)] - url: Url, - - /// The name of the broadcast to publish. - #[arg(long, alias = "name")] - broadcast: String, - - /// The format of the input media. - #[command(subcommand)] - format: PublishFormat, - }, - /// Subscribe to a broadcast on a remote relay and write the media to stdout. - Subscribe { - /// The MoQ client configuration. - #[command(flatten)] - config: moq_native::ClientConfig, - - /// The URL of the MoQ server. - #[arg(long)] - url: Url, - - /// The name of the broadcast to subscribe to. - #[arg(long, alias = "name")] - broadcast: String, - - #[command(flatten)] - args: SubscribeArgs, - }, - /// Import or export HLS / LL-HLS via a MoQ relay. - Hls { - /// The MoQ client configuration. - #[command(flatten)] - config: moq_native::ClientConfig, - - /// The URL of the MoQ server. - #[arg(long, alias = "relay", env = "MOQ_HLS_RELAY")] - url: Url, - - #[command(subcommand)] - command: HlsCommand, - }, + iroh: Option, } -#[derive(Subcommand, Clone)] -pub enum HlsCommand { - /// Serve HLS / LL-HLS over HTTP from MoQ broadcasts. - Export { - /// HTTP listener for the HLS endpoints. - #[arg(long, env = "MOQ_HLS_LISTEN", default_value = "[::]:8089")] - listen: SocketAddr, - - /// TLS certificates, keys, self-signed generation, and optional mTLS roots. - #[command(flatten)] - tls: moq_native::tls::Server, - - /// LL-HLS part target duration. - #[arg(long, env = "MOQ_HLS_PART_TARGET", default_value = "500ms", value_parser = humantime::parse_duration)] - part_target: Duration, - - /// Minimum duration of media kept in each rendition's sliding window. - #[arg(long, env = "MOQ_HLS_WINDOW", default_value = "16s", value_parser = humantime::parse_duration)] - window: Duration, - }, - /// Pull a remote HLS master/media playlist and publish it into MoQ. - Import { - /// Broadcast name to publish on the relay. - #[arg(long, alias = "name", env = "MOQ_HLS_BROADCAST")] - broadcast: String, +impl Net { + fn client(&self, config: moq_native::ClientConfig) -> anyhow::Result { + let client = config.init()?; + #[cfg(feature = "iroh")] + let client = client.with_iroh(self.iroh.clone()); + Ok(client) + } - /// Remote HLS playlist URL (http/https) or local file path. - #[arg(long, env = "MOQ_HLS_PLAYLIST")] - playlist: String, - }, + fn server(&self, config: moq_native::ServerConfig) -> anyhow::Result { + let server = config.init()?; + #[cfg(feature = "iroh")] + let server = server.with_iroh(self.iroh.clone()); + Ok(server) + } } #[tokio::main] @@ -171,266 +58,258 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); cli.log.init()?; - #[cfg(feature = "iroh")] - let iroh = cli.iroh.bind().await?; - - match cli.command { - Command::Serve { - config, - dir, - broadcast, - format, - } => { - warn_if_missing_format(&broadcast); - let publish = Publish::new(&format)?; - let web_bind = config.bind.clone().unwrap_or_else(|| "[::]:443".to_string()); - - let server = config.init()?; - #[cfg(feature = "iroh")] - let server = server.with_iroh(iroh); + let net = Net { + #[cfg(feature = "iroh")] + iroh: cli.moq.iroh.clone().bind().await?, + }; - let web_tls = server.tls_info(); + match cli.direction { + Direction::Import(import) => run_import(cli.moq, import, net).await, + Direction::Export(export) => run_export(cli.moq, export, net).await, + } +} - tokio::select! { - res = run_server(server, broadcast, publish.consume()) => res, - res = run_web(&web_bind, web_tls, dir) => res, - res = publish.run() => res, - } +/// Route one source INTO the shared Origin, exposing it to the MoQ network. +async fn run_import(moq: MoqSide, import: Import, net: Net) -> anyhow::Result<()> { + let origin = moq_net::Origin::random().produce(); + // The broadcast defaults to "": MoQ names each broadcast by the connection + // path plus any explicit `--broadcast`, so an unset name is the root broadcast. + let name = moq.broadcast.clone().unwrap_or_default(); + let mut tasks: JoinSet> = JoinSet::new(); + // RAII guards keeping our broadcasts announced until the endpoints finish. + let mut announce = Vec::new(); + // The stdin/capture pipeline runs on this task instead of the JoinSet: the + // platform capture stream is not Send, so its future cannot be spawned. + let mut local: Option = None; + + if let ImportSource::Rtc(rtc) = &import.source { + if rtc.connect.is_some() { + reject_listener_cors(&rtc.cors, "import rtc")?; } - Command::Accept { - config, - broadcast, - dir, - args, - } => { - let web_bind = config.bind.clone().unwrap_or_else(|| "[::]:443".to_string()); - - let server = config.init()?; - #[cfg(feature = "iroh")] - let server = server.with_iroh(iroh); - - let web_tls = server.tls_info(); + } - let origin = moq_net::Origin::random().produce(); - let consumer = origin.consume(); + // MoQ side: publish the Origin outward. + if let Some(url) = moq.client_connect.clone() { + let client = net.client(moq.client.clone())?; + let origin = origin.clone(); + tasks.spawn(async move { moq::client_import(client, url, &origin).await }); + } + if let Some(web_bind) = moq.server.bind.clone() { + let server = net.server(moq.server.clone())?; + let tls_info = server.tls_info(); + tasks.spawn(moq::server_import(server, origin.clone())); + tasks.spawn(async move { web::run_web(&web_bind, tls_info).await }); + } - tokio::select! { - res = run_accept(server, origin) => res, - res = run_web(&web_bind, web_tls, dir) => res, - res = run_announced_subscribe(consumer, broadcast, args) => res, - _ = tokio::signal::ctrl_c() => Ok(()), + // Foreign side: the single source. + if let Some(format) = import.source.stdin_format() { + warn_if_missing_format(&name); + let publish = Publish::new(&format)?; + announce.push( + origin + .publish_broadcast(&name, publish.consume()) + .context("failed to publish broadcast")?, + ); + local = Some(publish); + } else { + match import.source { + ImportSource::Hls(hls) => { + warn_if_missing_format(&name); + let origin = origin.clone(); + tasks.spawn(async move { hls::import(&origin, name, hls.playlist).await }); } - } - Command::Publish { - config, - url, - broadcast, - format, - } => { - warn_if_missing_format(&broadcast); - let publish = Publish::new(&format)?; - let client = config.init()?; - - #[cfg(feature = "iroh")] - let client = client.with_iroh(iroh); - - run_client(client, url, broadcast, publish).await - } - Command::Subscribe { - config, - url, - broadcast, - args, - } => { - let client = config.init()?; - - #[cfg(feature = "iroh")] - let client = client.with_iroh(iroh); - - run_subscribe(client, url, broadcast, args).await - } - Command::Hls { config, url, command } => { - let client = config.init()?; - - #[cfg(feature = "iroh")] - let client = client.with_iroh(iroh); - - match command { - HlsCommand::Export { - listen, - tls, - part_target, - window, - } => run_hls_export(client, url, listen, tls, part_target, window).await, - HlsCommand::Import { broadcast, playlist } => run_hls_import(client, url, broadcast, playlist).await, + ImportSource::Rtmp(rtmp) => { + if let Some(addr) = rtmp.listen { + let name = require_broadcast(name, "import rtmp --listen")?; + tasks.spawn(rtmp::listen_import(origin.clone(), addr, name)); + } else if let Some(url) = rtmp.connect { + tasks.spawn(rtmp::connect_import(origin.clone(), url, name)); + } } + ImportSource::Srt(srt) => { + if let Some(addr) = srt.listen { + let name = require_broadcast(name, "import srt --listen")?; + tasks.spawn(srt::listen_import(origin.clone(), addr, name, srt.latency)); + } else if let Some(url) = srt.connect { + tasks.spawn(srt::connect_import(origin.clone(), url, name, srt.latency)); + } + } + ImportSource::Rtc(rtc) => { + if let Some(addr) = rtc.listen { + let name = require_broadcast(name, "import rtc --listen")?; + tasks.spawn(rtc::listen_import( + origin.clone(), + addr, + rtc.udp_bind, + rtc.public_addr, + rtc.cors, + name, + )); + } else if let Some(url) = rtc.connect { + tasks.spawn(rtc::connect_import(origin.clone(), url, name)); + } + } + #[cfg(feature = "capture")] + ImportSource::Capture(capture) => { + warn_if_missing_format(&name); + let publish = Publish::capture(&capture)?; + announce.push( + origin + .publish_broadcast(&name, publish.consume()) + .context("failed to publish broadcast")?, + ); + local = Some(publish); + } + _ => unreachable!("container formats are handled by stdin_format above"), } } -} -fn warn_if_missing_format(name: &str) { - if moq_mux::catalog::CatalogFormat::detect(name).is_none() { - tracing::warn!( - name, - "You should append .hang to your broadcast name to make the catalog format explicit." - ); + match local { + Some(publish) => tokio::select! { + res = publish.run() => res, + res = drive(tasks) => res, + }, + None => drive(tasks).await, } } -async fn run_subscribe( - client: moq_native::Client, - url: Url, - broadcast: String, - args: SubscribeArgs, -) -> anyhow::Result<()> { +/// Route the shared Origin OUT to one sink, filling it from the MoQ network. +async fn run_export(moq: MoqSide, export: Export, net: Net) -> anyhow::Result<()> { let origin = moq_net::Origin::random().produce(); - let consumer = origin.consume(); - - tracing::info!(%url, %broadcast, "connecting"); - - let reconnect = client.with_subscriber(origin).reconnect(url); - - #[cfg(unix)] - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); - - tokio::select! { - res = run_announced_subscribe(consumer, broadcast, args) => res, - res = reconnect.closed() => Ok(res?), - _ = tokio::signal::ctrl_c() => Ok(()), + // The broadcast defaults to "": MoQ names each broadcast by the connection + // path plus any explicit `--broadcast`, so an unset name is the root broadcast. + let name = moq.broadcast.clone().unwrap_or_default(); + let mut tasks: JoinSet> = JoinSet::new(); + + if let ExportSink::Rtc(rtc) = &export.sink { + if rtc.connect.is_some() { + reject_listener_cors(&rtc.cors, "export rtc")?; + } } -} - -async fn run_announced_subscribe( - consumer: moq_net::OriginConsumer, - broadcast: String, - args: SubscribeArgs, -) -> anyhow::Result<()> { - let catalog = args.catalog_format(&broadcast); - - let consumer = consumer - .announced_broadcast(&broadcast) - .await - .ok_or_else(|| anyhow::anyhow!("origin closed before broadcast was announced"))?; - - Subscribe::new(consumer, catalog, args).run().await -} -async fn run_hls_export( - client: moq_native::Client, - url: Url, - listen: SocketAddr, - tls: moq_native::tls::Server, - part_target: Duration, - window: Duration, -) -> anyhow::Result<()> { - let subscriber = moq_net::Origin::random().produce(); - let consumer = subscriber.consume(); - let reconnect = client.with_subscriber(subscriber).reconnect(url.clone()); - - let config = moq_hls::export::Config { - part_target, - window, - ..Default::default() - }; - let server = moq_hls::Server::new(consumer, config); - let app = server - .router() - .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any)); + // MoQ side: fill the Origin. + if let Some(url) = moq.client_connect.clone() { + let client = net.client(moq.client.clone())?; + let origin = origin.clone(); + tasks.spawn(async move { moq::client_export(client, url, origin).await }); + } + if let Some(web_bind) = moq.server.bind.clone() { + let server = net.server(moq.server.clone())?; + let tls_info = server.tls_info(); + tasks.spawn(moq::server_export(server, origin.clone())); + tasks.spawn(async move { web::run_web(&web_bind, tls_info).await }); + } - let tls = if tls.cert.is_empty() && tls.generate.is_empty() { - None + // Foreign side: the single sink. + if let Some((format, max_latency, fragment_duration)) = export.sink.stdout() { + let args = SubscribeArgs { + format, + max_latency, + fragment_duration, + catalog: export.catalog_format, + select: export.select, + }; + let consumer = origin.consume(); + tasks.spawn(async move { run_stdout(consumer, name, args).await }); } else { - let alpn = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - Some(tls.server_config(alpn)?) - }; - - let listener = moq_native::bind::tcp(listen)?; - - tracing::info!(%url, %listen, "serving HLS"); - - #[cfg(unix)] - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); - - tokio::select! { - res = serve_hls(listener, app, tls) => res, - res = reconnect.closed() => res.map_err(Into::into), - _ = shutdown_signal() => Ok(()), + match export.sink { + ExportSink::Hls(args) => { + let name = require_broadcast(name, "export hls")?; + tasks.spawn(hls::export(origin.consume(), args, name)); + } + ExportSink::Rtmp(rtmp) => { + if let Some(addr) = rtmp.endpoint.listen { + let name = require_broadcast(name, "export rtmp --listen")?; + tasks.spawn(rtmp::listen_export(origin.consume(), addr, name, rtmp.latency_max)); + } else if let Some(url) = rtmp.endpoint.connect { + tasks.spawn(rtmp::connect_export(origin.consume(), url, name, rtmp.latency_max)); + } + } + ExportSink::Srt(srt) => { + if let Some(addr) = srt.listen { + let name = require_broadcast(name, "export srt --listen")?; + tasks.spawn(srt::listen_export(origin.consume(), addr, name, srt.latency)); + } else if let Some(url) = srt.connect { + tasks.spawn(srt::connect_export(origin.consume(), url, name, srt.latency)); + } + } + ExportSink::Rtc(rtc) => { + if let Some(addr) = rtc.listen { + let name = require_broadcast(name, "export rtc --listen")?; + tasks.spawn(rtc::listen_export( + origin.consume(), + addr, + rtc.udp_bind, + rtc.public_addr, + rtc.cors, + name, + )); + } else if let Some(url) = rtc.connect { + tasks.spawn(rtc::connect_export(origin.consume(), url, name)); + } + } + _ => unreachable!("container formats are handled by stdout_format above"), + } } -} - -async fn run_hls_import( - client: moq_native::Client, - url: Url, - broadcast: String, - playlist: String, -) -> anyhow::Result<()> { - warn_if_missing_format(&broadcast); - - let publisher = moq_net::Origin::random().produce(); - let reconnect = client.with_publisher(publisher.consume()).reconnect(url.clone()); - - let mut producer = moq_net::BroadcastInfo::new().produce(); - let consumer = producer.consume(); - // Held for the import's lifetime; dropping the guard unannounces the broadcast. - let _publish = publisher.publish_broadcast(&broadcast, consumer)?; - - let catalog = moq_mux::catalog::Producer::new(&mut producer)?; - let mut importer = moq_hls::import::Import::new(producer, catalog, moq_hls::import::Config::new(playlist))?; - tracing::info!(%url, %broadcast, "importing HLS"); - - tokio::select! { - res = async { - importer.init().await?; + drive(tasks).await +} - #[cfg(unix)] - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); +/// Subscribe to `name` from the Origin and write it to stdout. +async fn run_stdout(consumer: moq_net::OriginConsumer, name: String, args: SubscribeArgs) -> anyhow::Result<()> { + let catalog = args.catalog_format(&name); + let broadcast = consumer + .announced_broadcast(&name) + .await + .ok_or_else(|| anyhow::anyhow!("origin closed before broadcast `{name}` was announced"))?; - importer.run().await - } => res.map_err(Into::into), - res = reconnect.closed() => res.map_err(Into::into), - _ = shutdown_signal() => Ok(()), - } + Subscribe::new(broadcast, catalog, args).run().await } -async fn serve_hls( - listener: std::net::TcpListener, - app: axum::Router, - tls: Option>, -) -> anyhow::Result<()> { - let service = app.into_make_service(); - match tls { - Some(config) => { - let config = axum_server::tls_rustls::RustlsConfig::from_config(config); - axum_server::from_tcp_rustls(listener, config)?.serve(service).await?; - } - None => { - axum_server::from_tcp(listener)?.serve(service).await?; +/// Run every endpoint until the first finishes (stdin EOF, Ctrl-C, or an error), +/// then drop the rest. +async fn drive(mut tasks: JoinSet>) -> anyhow::Result<()> { + tasks.spawn(async { + let _ = tokio::signal::ctrl_c().await; + Ok(()) + }); + + while let Some(res) = tasks.join_next().await { + match res { + Ok(Ok(())) => return Ok(()), + Ok(Err(err)) => return Err(err), + Err(err) if err.is_cancelled() => continue, + Err(err) => return Err(err.into()), } } + Ok(()) } -async fn shutdown_signal() { - #[cfg(unix)] - { - use tokio::signal::unix::{SignalKind, signal}; +/// The listener / HTTP-serving endpoints bridge one named broadcast, so an +/// empty `--broadcast` is rejected rather than silently defaulting to the root. +fn require_broadcast(name: String, endpoint: &str) -> anyhow::Result { + anyhow::ensure!( + !name.is_empty(), + "`{endpoint}` requires a broadcast: pass --broadcast " + ); + Ok(name) +} - let mut term = match signal(SignalKind::terminate()) { - Ok(term) => term, - Err(_) => { - let _ = tokio::signal::ctrl_c().await; - return; - } - }; - tokio::select! { - _ = tokio::signal::ctrl_c() => {} - _ = term.recv() => {} - } +fn warn_if_missing_format(name: &str) { + // The empty (root) broadcast has no name to suffix, so there's nothing to warn about. + if !name.is_empty() && moq_mux::catalog::CatalogFormat::detect(name).is_none() { + tracing::warn!( + name, + "You should append .hang to your broadcast name to make the catalog format explicit." + ); } +} - #[cfg(not(unix))] - { - let _ = tokio::signal::ctrl_c().await; - } +fn reject_listener_cors(cors: &crate::web::Cors, endpoint: &str) -> anyhow::Result<()> { + anyhow::ensure!( + cors.origin.is_empty(), + "`--cors-origin` only applies to `{endpoint} --listen`" + ); + Ok(()) } diff --git a/rs/moq-cli/src/moq.rs b/rs/moq-cli/src/moq.rs new file mode 100644 index 0000000000..c8082368f8 --- /dev/null +++ b/rs/moq-cli/src/moq.rs @@ -0,0 +1,83 @@ +//! The MoQ side of the router: attaching the shared Origin to the network via a +//! dialed client (`--client-connect`) and/or a hosted server (`--server-bind`). +//! +//! Direction determines data flow off the one shared Origin: +//! - import: MoQ publishes the Origin outward (client publishes to the relay; +//! each accepted server session serves subscribers). +//! - export: MoQ fills the Origin (client subscribes from the relay; each +//! accepted server session ingests a remote publish). + +use hang::moq_net; +use url::Url; + +/// Dial a relay and publish the Origin's broadcasts to it (import). +pub async fn client_import( + client: moq_native::Client, + url: Url, + origin: &moq_net::OriginProducer, +) -> anyhow::Result<()> { + let reconnect = client.with_publisher(origin).reconnect(url); + notify_ready(); + Ok(reconnect.closed().await?) +} + +/// Dial a relay and subscribe its broadcasts into the Origin (export). +pub async fn client_export( + client: moq_native::Client, + url: Url, + origin: moq_net::OriginProducer, +) -> anyhow::Result<()> { + let reconnect = client.with_subscriber(origin).reconnect(url); + notify_ready(); + Ok(reconnect.closed().await?) +} + +/// Host a MoQ server; each accepted session serves the Origin to subscribers (import). +pub async fn server_import(mut server: moq_native::Server, origin: moq_net::OriginProducer) -> anyhow::Result<()> { + notify_ready(); + tracing::info!(addr = ?server.local_addr(), "listening"); + + while let Some(session) = server.accept().await { + let origin = origin.clone(); + tokio::spawn(async move { + if let Err(err) = serve_session(session, origin).await { + tracing::warn!(%err, "session ended with error"); + } + }); + } + + Ok(()) +} + +async fn serve_session(session: moq_native::Request, origin: moq_net::OriginProducer) -> anyhow::Result<()> { + let session = session.with_publisher(&origin).ok().await?; + Ok(session.closed().await?) +} + +/// Host a MoQ server; each accepted session ingests a remote publish into the Origin (export). +pub async fn server_export(mut server: moq_native::Server, origin: moq_net::OriginProducer) -> anyhow::Result<()> { + notify_ready(); + tracing::info!(addr = ?server.local_addr(), "listening"); + + while let Some(session) = server.accept().await { + let origin = origin.clone(); + tokio::spawn(async move { + if let Err(err) = accept_session(session, origin).await { + tracing::warn!(%err, "session ended with error"); + } + }); + } + + Ok(()) +} + +async fn accept_session(session: moq_native::Request, origin: moq_net::OriginProducer) -> anyhow::Result<()> { + let session = session.with_subscriber(origin).ok().await?; + Ok(session.closed().await?) +} + +/// Notify systemd (if any) that the endpoint is up. +pub fn notify_ready() { + #[cfg(unix)] + let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); +} diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index ee20301246..1b5caf415a 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -1,25 +1,17 @@ -use clap::Subcommand; use hang::moq_net; -use moq_hls::import as hls; use moq_mux::container::{flv, fmp4, ts}; -#[derive(Subcommand, Clone)] +/// Container format read from stdin on the import (source) side. +#[derive(Clone, Copy)] pub enum PublishFormat { + /// Raw AVC (H.264) Annex B elementary stream. Avc3, + /// Fragmented MP4 (CMAF). Fmp4, - /// MPEG-TS (transport stream) read from stdin. + /// MPEG-TS (transport stream). Ts, - /// FLV (Flash Video / RTMP) read from stdin. + /// FLV (Flash Video / RTMP). Flv, - // NOTE: No aac support because it needs framing. - Hls { - /// URL or file path of an HLS playlist to ingest. - #[arg(long)] - playlist: String, - }, - /// Capture and publish the camera (H.264) and microphone (Opus). - #[cfg(feature = "capture")] - Capture(CaptureArgs), } /// `clap` adapter for [`moq_video::encode::Codec`]. @@ -114,31 +106,38 @@ enum PublishDecoder { // verbatim, so it uses the `mpegts` catalog extension rather than the media-only `()`. Ts(Box>), Flv(Box), - Hls(Box), } impl PublishDecoder { - /// Decode a chunk of bytes from stdin (Avc3, Fmp4, Ts, or Flv). - fn decode_buf(&mut self, data: &[u8]) -> anyhow::Result<()> { + /// Decode a chunk of stdin bytes. Each importer buffers any partial trailing + /// frame internally, so the caller feeds fresh chunks rather than an + /// accumulating buffer. + fn decode_chunk(&mut self, chunk: &[u8]) -> anyhow::Result<()> { match self { Self::Avc3 { split, import } => { - let frames = split.decode(data, None)?; + let frames = split.decode(chunk, None)?; import.decode(frames)?; - Ok(()) } - Self::Fmp4(d) => Ok(d.decode(data)?), - Self::Ts(d) => Ok(d.decode(data)?), - Self::Flv(d) => Ok(d.decode(data)?), - Self::Hls(_) => unreachable!(), + Self::Fmp4(d) => d.decode(chunk)?, + Self::Ts(d) => d.decode(chunk)?, + Self::Flv(d) => d.decode(chunk)?, } + Ok(()) } - /// Flush any in-flight access unit at end of stream. The avc3 split holds the - /// final AU until the next start code, so stdin EOF must flush it. + /// Flush any buffered trailing frame and close the tracks at end of input. + /// The avc3 split holds the final access unit until the next start code, so + /// stdin EOF must flush it explicitly. fn finish(&mut self) -> anyhow::Result<()> { - if let Self::Avc3 { split, import } = self { - let tail = split.flush(None)?; - import.decode(tail)?; + match self { + Self::Avc3 { split, import } => { + let tail = split.flush(None)?; + import.decode(tail)?; + import.finish()?; + } + Self::Fmp4(d) => d.finish()?, + Self::Ts(d) => d.finish()?, + Self::Flv(d) => d.finish()?, } Ok(()) } @@ -148,7 +147,7 @@ impl PublishDecoder { // Stream variant and the larger Capture config is irrelevant. #[allow(clippy::large_enum_variant)] enum Source { - /// Decode a container read from stdin (or an HLS playlist). + /// Decode a container read from stdin. Stream(PublishDecoder), /// Capture from local devices. The per-medium producers are built on their /// own capture threads (native camera/screen capture, microphone via cpal), publishing @@ -162,12 +161,15 @@ enum Source { }, } +/// A single-broadcast publisher: decodes stdin (or captures local devices) into +/// a broadcast that the MoQ side announces. pub struct Publish { source: Source, broadcast: moq_net::BroadcastProducer, } impl Publish { + /// Build a publisher decoding the given container format from stdin. pub fn new(format: &PublishFormat) -> anyhow::Result { let mut broadcast = moq_net::BroadcastInfo::new().produce(); @@ -207,32 +209,35 @@ impl Publish { let flv = flv::Import::new(broadcast.clone(), catalog.clone()); Source::Stream(PublishDecoder::Flv(Box::new(flv))) } - PublishFormat::Hls { playlist } => { - let hls = hls::Import::new(broadcast.clone(), catalog.clone(), hls::Config::new(playlist.clone()))?; - Source::Stream(PublishDecoder::Hls(Box::new(hls))) - } - #[cfg(feature = "capture")] - PublishFormat::Capture(args) => { - let video = (!args.no_video).then(|| (args.video_config(), args.video_encode())); - let audio = (!args.no_audio).then(|| (args.audio_config(), args.audio_encode())); - anyhow::ensure!(video.is_some() || audio.is_some(), "nothing to capture"); - Source::Capture { catalog, video, audio } - } }; Ok(Self { source, broadcast }) } + /// Build a publisher capturing local devices (camera/screen and microphone). + #[cfg(feature = "capture")] + pub fn capture(args: &CaptureArgs) -> anyhow::Result { + let mut broadcast = moq_net::BroadcastInfo::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast)?; + + let video = (!args.no_video).then(|| (args.video_config(), args.video_encode())); + let audio = (!args.no_audio).then(|| (args.audio_config(), args.audio_encode())); + anyhow::ensure!(video.is_some() || audio.is_some(), "nothing to capture"); + + Ok(Self { + source: Source::Capture { catalog, video, audio }, + broadcast, + }) + } + + /// A consumer of the broadcast being published, for announcing it on an Origin. pub fn consume(&self) -> moq_net::BroadcastConsumer { self.broadcast.consume() } + /// Drive the source until stdin EOF (or the capture devices stop). pub async fn run(self) -> anyhow::Result<()> { match self.source { - Source::Stream(PublishDecoder::Hls(mut decoder)) => { - decoder.init().await?; - Ok(decoder.run().await?) - } Source::Stream(mut decoder) => { let mut stdin = tokio::io::stdin(); let mut buffer = bytes::BytesMut::new(); @@ -241,10 +246,11 @@ impl Publish { buffer.clear(); let n = tokio::io::AsyncReadExt::read_buf(&mut stdin, &mut buffer).await?; if n == 0 { + // EOF: flush the importer's buffered trailing frame and close the tracks. decoder.finish()?; return Ok(()); } - decoder.decode_buf(&buffer)?; + decoder.decode_chunk(&buffer)?; } } #[cfg(feature = "capture")] @@ -478,11 +484,8 @@ mod tests { 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(); + decoder.decode_chunk(&input).unwrap(); + decoder.finish().unwrap(); // Subscribe side: the same `with_ts` call `run_ts` makes, re-emitting the // ancillary streams verbatim. diff --git a/rs/moq-cli/src/rtc.rs b/rs/moq-cli/src/rtc.rs new file mode 100644 index 0000000000..c5a3aca238 --- /dev/null +++ b/rs/moq-cli/src/rtc.rs @@ -0,0 +1,132 @@ +//! WebRTC (WHIP/WHEP) endpoints. Direction picks the HTTP role: +//! - import `--listen` = WHIP server (accept publishes); `--connect` = WHEP client (pull). +//! - export `--listen` = WHEP server (serve plays); `--connect` = WHIP client (push). + +use std::net::SocketAddr; + +use anyhow::Context; +use axum::http::Method; +use hang::moq_net; +use hang::moq_net::AsPath; +use url::Url; + +use crate::moq::notify_ready; + +/// WebRTC endpoint args: exactly one of `--connect` (WHIP/WHEP client) / +/// `--listen` (WHIP/WHEP server). The parent direction picks WHIP vs WHEP. +#[derive(clap::Args, Clone)] +#[command(group = clap::ArgGroup::new("rtc-mode").required(true).multiple(false).args(["rtc-connect", "rtc-listen"]))] +pub struct Args { + /// Dial a remote WHIP/WHEP endpoint URL. + #[arg(id = "rtc-connect", long = "connect", value_name = "URL")] + pub connect: Option, + + /// Bind an HTTP listener for WHIP/WHEP, scoped to the single `--broadcast` + /// (peers reach it at `http://host:port/`). + #[arg(id = "rtc-listen", long = "listen", value_name = "ADDR")] + pub listen: Option, + + /// Shared UDP socket for ICE/media (one port for all sessions). + #[arg(long, requires = "rtc-listen", default_value = "[::]:0")] + pub udp_bind: SocketAddr, + + /// Public UDP address(es) advertised as ICE host candidates (repeatable). + #[arg(long, requires = "rtc-listen")] + pub public_addr: Vec, + + /// Browser CORS policy for the WHIP/WHEP listener. + #[command(flatten)] + pub cors: crate::web::Cors, +} + +/// WHIP server: accept incoming WebRTC publishes into the Origin as `name` (import). +pub async fn listen_import( + origin: moq_net::OriginProducer, + listen: SocketAddr, + udp_bind: SocketAddr, + public_addr: Vec, + cors: crate::web::Cors, + name: String, +) -> anyhow::Result<()> { + let publisher = scope_producer(&origin, &name)?; + let server = server(publisher, origin.consume(), udp_bind, public_addr); + serve(server.publish_router(), listen, "WHIP", cors).await +} + +/// WHEP server: serve WebRTC plays of `name` from the Origin (export). +pub async fn listen_export( + origin: moq_net::OriginConsumer, + listen: SocketAddr, + udp_bind: SocketAddr, + public_addr: Vec, + cors: crate::web::Cors, + name: String, +) -> anyhow::Result<()> { + let subscriber = origin + .scope(&[name.as_path()]) + .with_context(|| format!("failed to scope origin to broadcast `{name}`"))?; + // A WHEP server only reads; it still needs a publisher handle for the shared + // glue, so hand it an unused, empty Origin producer. + let publisher = moq_net::Origin::random().produce(); + let server = server(publisher, subscriber, udp_bind, public_addr); + serve(server.subscribe_router(), listen, "WHEP", cors).await +} + +/// Restrict a producer to the single broadcast `name` so a WHIP peer can only publish it. +fn scope_producer(origin: &moq_net::OriginProducer, name: &str) -> anyhow::Result { + origin + .scope(&[name.as_path()]) + .with_context(|| format!("failed to scope origin to broadcast `{name}`")) +} + +/// WHEP client: pull a remote broadcast into the Origin under `name` (import). +pub async fn connect_import(origin: moq_net::OriginProducer, url: Url, name: String) -> anyhow::Result<()> { + let producer = moq_net::BroadcastInfo::new().produce(); + // Hold the RAII announcement for the lifetime of the pull. + let _announce = origin + .publish_broadcast(&name, producer.consume()) + .context("failed to publish broadcast")?; + + tracing::info!(%url, %name, "WHEP client pulling"); + notify_ready(); + + let client = moq_rtc::Client::new(moq_rtc::client::Config::default()); + Ok(client.subscribe(url, producer).await?) +} + +/// WHIP client: push a broadcast from the Origin to a remote (export). +pub async fn connect_export(origin: moq_net::OriginConsumer, url: Url, name: String) -> anyhow::Result<()> { + let broadcast = origin + .announced_broadcast(&name) + .await + .with_context(|| format!("origin closed before broadcast `{name}` was announced"))?; + + tracing::info!(%url, %name, "WHIP client pushing"); + notify_ready(); + + let client = moq_rtc::Client::new(moq_rtc::client::Config::default()); + Ok(client.publish(url, broadcast).await?) +} + +fn server( + publisher: moq_net::OriginProducer, + subscriber: moq_net::OriginConsumer, + udp_bind: SocketAddr, + public_addr: Vec, +) -> moq_rtc::Server { + let mut config = moq_rtc::server::Config::default(); + config.udp_bind = udp_bind; + config.ice_candidates = public_addr; + moq_rtc::Server::new(config, publisher, subscriber) +} + +async fn serve(router: axum::Router, listen: SocketAddr, role: &str, cors: crate::web::Cors) -> anyhow::Result<()> { + let cors = cors.layer([Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])?; + let app = router.layer(cors); + let listener = moq_native::bind::tcp(listen)?; + + tracing::info!(%listen, role, "serving WebRTC"); + notify_ready(); + + crate::web::serve(listener, app, None).await +} diff --git a/rs/moq-cli/src/rtmp.rs b/rs/moq-cli/src/rtmp.rs new file mode 100644 index 0000000000..4406dd38ad --- /dev/null +++ b/rs/moq-cli/src/rtmp.rs @@ -0,0 +1,195 @@ +//! RTMP endpoints. Listeners are directional: an import listener accepts +//! publishes only (rejecting plays), an export listener serves plays only +//! (rejecting publishes). The operator declares direction; the peer can't choose. + +use std::net::SocketAddr; +use std::time::Duration; + +use anyhow::Context; +use hang::moq_net; +use moq_rtmp::{Client, Request, Server}; +use url::Url; + +use crate::moq::notify_ready; + +/// RTMP endpoint args: exactly one of `--connect` (dial) / `--listen` (bind). +/// The parent direction fixes whether that dial/bind pushes or pulls. Import uses +/// this directly; export wraps it in [`ExportArgs`] for the egress-only knobs. +#[derive(clap::Args, Clone)] +#[command(group = clap::ArgGroup::new("rtmp-mode").required(true).multiple(false).args(["rtmp-connect", "rtmp-listen"]))] +pub struct Args { + /// Dial `rtmp://host[:1935]//`. + #[arg(id = "rtmp-connect", long = "connect", value_name = "URL")] + pub connect: Option, + + /// Bind an RTMP listener, bridging the single `--broadcast` (the RTMP app/key + /// is accepted but not used for routing). + #[arg(id = "rtmp-listen", long = "listen", value_name = "ADDR")] + pub listen: Option, +} + +/// RTMP export args: the endpoint plus egress-only tuning. Split from the import +/// side so the frame-drop knob only shows where it applies. +#[derive(clap::Args, Clone)] +pub struct ExportArgs { + #[command(flatten)] + pub endpoint: Args, + + /// Maximum latency before skipping a stalled group. RTMP is unpaced, so this + /// bounds buffering, not the wire rate. + #[arg(long = "latency-max", default_value = "500ms", value_parser = humantime::parse_duration)] + pub latency_max: Duration, +} + +/// Accept incoming RTMP publishes into the Origin as `name`; reject plays (import). +pub async fn listen_import(origin: moq_net::OriginProducer, addr: SocketAddr, name: String) -> anyhow::Result<()> { + let mut server = Server::bind(addr).await?; + tracing::info!(%addr, %name, "RTMP listening (import)"); + notify_ready(); + + while let Some(request) = server.accept().await { + match request { + Request::Publish(publish) => { + let origin = origin.clone(); + let name = name.clone(); + tokio::spawn(async move { + if let Err(err) = publish.accept(&origin, &name).await { + tracing::warn!(%name, %err, "RTMP ingest ended with error"); + } + }); + } + Request::Play(play) => { + tokio::spawn(async move { + let _ = play.reject("this is an import listener; it does not serve plays").await; + }); + } + _ => {} + } + } + + Ok(()) +} + +/// Serve RTMP plays of `name` from the Origin; reject publishes (export). +pub async fn listen_export( + origin: moq_net::OriginConsumer, + addr: SocketAddr, + name: String, + latency: Duration, +) -> anyhow::Result<()> { + let mut server = Server::bind(addr).await?; + tracing::info!(%addr, %name, "RTMP listening (export)"); + notify_ready(); + + while let Some(request) = server.accept().await { + match request { + Request::Play(play) => { + let origin = origin.clone(); + let name = name.clone(); + tokio::spawn(async move { + if let Err(err) = play.with_latency(latency).accept(&origin, &name).await { + tracing::warn!(%name, %err, "RTMP play ended with error"); + } + }); + } + Request::Publish(publish) => { + tokio::spawn(async move { + let _ = publish + .reject("this is an export listener; it does not accept publishes") + .await; + }); + } + _ => {} + } + } + + Ok(()) +} + +/// Dial a remote RTMP server and pull its play into the Origin under `name` (import). +pub async fn connect_import(origin: moq_net::OriginProducer, url: Url, name: String) -> anyhow::Result<()> { + let (addr, app, key) = parse_url(&url).await?; + tracing::info!(%url, %name, "RTMP client pulling"); + notify_ready(); + + let client = Client::connect(addr, &app).await?; + Ok(client.pull(&key, &origin, &name).await?) +} + +/// Push a broadcast from the Origin to a remote RTMP server (export). +pub async fn connect_export( + origin: moq_net::OriginConsumer, + url: Url, + name: String, + latency: Duration, +) -> anyhow::Result<()> { + let (addr, app, key) = parse_url(&url).await?; + let broadcast = origin + .announced_broadcast(&name) + .await + .with_context(|| format!("origin closed before broadcast `{name}` was announced"))?; + + tracing::info!(%url, %name, "RTMP client pushing"); + notify_ready(); + + let client = Client::connect(addr, &app).await?.with_latency(latency); + Ok(client.publish(&key, broadcast).await?) +} + +/// Parse `rtmp://host[:1935]//` into a resolved address, app, and stream key. +async fn parse_url(url: &Url) -> anyhow::Result<(SocketAddr, String, String)> { + anyhow::ensure!(url.scheme() == "rtmp", "rtmp url must use the rtmp scheme: {url}"); + + let host = url + .host_str() + .with_context(|| format!("rtmp url missing host: {url}"))?; + let port = url.port().unwrap_or(1935); + let addr = tokio::net::lookup_host((host, port)) + .await? + .next() + .with_context(|| format!("could not resolve {host}:{port}"))?; + + let mut segments = url.path().trim_matches('/').splitn(2, '/'); + let app = segments.next().unwrap_or_default().to_string(); + let key = segments.next().unwrap_or_default().to_string(); + anyhow::ensure!( + !app.is_empty() && !key.is_empty(), + "rtmp url must include an app and stream key: rtmp://host//" + ); + + Ok((addr, app, key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Numeric hosts resolve without touching DNS, so these stay offline. + async fn parse(url: &str) -> anyhow::Result<(SocketAddr, String, String)> { + parse_url(&Url::parse(url).unwrap()).await + } + + #[tokio::test] + async fn ok_default_port() { + let (addr, app, key) = parse("rtmp://127.0.0.1/live/cam0").await.unwrap(); + assert_eq!(addr.port(), 1935); + assert_eq!((app.as_str(), key.as_str()), ("live", "cam0")); + } + + #[tokio::test] + async fn ok_explicit_port() { + let (addr, _, _) = parse("rtmp://127.0.0.1:1936/live/cam0").await.unwrap(); + assert_eq!(addr.port(), 1936); + } + + #[tokio::test] + async fn rejects_non_rtmp_scheme() { + assert!(parse("http://127.0.0.1/live/cam0").await.is_err()); + } + + #[tokio::test] + async fn requires_app_and_key() { + assert!(parse("rtmp://127.0.0.1/live").await.is_err()); + assert!(parse("rtmp://127.0.0.1/").await.is_err()); + } +} diff --git a/rs/moq-cli/src/server.rs b/rs/moq-cli/src/server.rs deleted file mode 100644 index 1caadfd1fb..0000000000 --- a/rs/moq-cli/src/server.rs +++ /dev/null @@ -1,91 +0,0 @@ -use anyhow::Context; -use hang::moq_net; - -pub async fn run_server( - mut server: moq_native::Server, - name: String, - consumer: moq_net::BroadcastConsumer, -) -> anyhow::Result<()> { - #[cfg(unix)] - // Notify systemd that we're ready. - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); - - let mut conn_id = 0; - - tracing::info!(addr = ?server.local_addr(), "listening"); - - while let Some(session) = server.accept().await { - let id = conn_id; - conn_id += 1; - - let name = name.clone(); - - let consumer = consumer.clone(); - // Handle the connection in a new task. - tokio::spawn(async move { - if let Err(err) = run_serve_session(id, session, name, consumer).await { - tracing::warn!(%err, "failed to accept session"); - } - }); - } - - Ok(()) -} - -#[tracing::instrument("session", skip_all, fields(id))] -async fn run_serve_session( - id: u64, - session: moq_native::Request, - name: String, - consumer: moq_net::BroadcastConsumer, -) -> anyhow::Result<()> { - // Create an origin producer to publish to the broadcast. - let origin = moq_net::Origin::random().produce(); - let _publish = origin - .publish_broadcast(&name, &consumer) - .context("failed to publish broadcast")?; - - // Blindly accept the session (WebTransport or QUIC), regardless of the URL. - let session = session.with_publisher(&origin).ok().await?; - - tracing::info!(id, "accepted session"); - - session.closed().await.map_err(Into::into) -} - -pub async fn run_accept(mut server: moq_native::Server, origin: moq_net::OriginProducer) -> anyhow::Result<()> { - #[cfg(unix)] - // Notify systemd that we're ready. - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); - - let mut conn_id = 0; - - tracing::info!(addr = ?server.local_addr(), "listening"); - - while let Some(session) = server.accept().await { - let id = conn_id; - conn_id += 1; - - let origin = origin.clone(); - tokio::spawn(async move { - if let Err(err) = run_accept_session(id, session, origin).await { - tracing::warn!(%err, "failed to accept session"); - } - }); - } - - Ok(()) -} - -#[tracing::instrument("session", skip_all, fields(id))] -async fn run_accept_session( - id: u64, - session: moq_native::Request, - origin: moq_net::OriginProducer, -) -> anyhow::Result<()> { - let session = session.with_subscriber(origin).ok().await?; - - tracing::info!(id, "accepted session"); - - session.closed().await.map_err(Into::into) -} diff --git a/rs/moq-cli/src/srt.rs b/rs/moq-cli/src/srt.rs new file mode 100644 index 0000000000..90a55807f9 --- /dev/null +++ b/rs/moq-cli/src/srt.rs @@ -0,0 +1,182 @@ +//! SRT endpoints. Like RTMP, listeners are directional: an import listener +//! accepts publishes only, an export listener serves requests only. + +use std::net::SocketAddr; +use std::time::Duration; + +use anyhow::Context; +use hang::moq_net; +use moq_srt::{Request, Server}; +use url::Url; + +use crate::moq::notify_ready; + +/// SRT endpoint args: exactly one of `--connect` (dial) / `--listen` (bind). +#[derive(clap::Args, Clone)] +#[command(group = clap::ArgGroup::new("srt-mode").required(true).multiple(false).args(["srt-connect", "srt-listen"]))] +pub struct Args { + /// Dial `srt://host:port?streamid=...`. + #[arg(id = "srt-connect", long = "connect", value_name = "URL")] + pub connect: Option, + + /// Bind an SRT listener, bridging the single `--broadcast` (the SRT stream id + /// is accepted but not used for routing). + #[arg(id = "srt-listen", long = "listen", value_name = "ADDR")] + pub listen: Option, + + /// SRT receive latency: the buffering delay traded for loss-recovery headroom. + #[arg(long, default_value = "200ms", value_parser = humantime::parse_duration)] + pub latency: Duration, +} + +/// Accept incoming SRT publishes into the Origin as `name`; reject requests (import). +pub async fn listen_import( + origin: moq_net::OriginProducer, + addr: SocketAddr, + name: String, + latency: Duration, +) -> anyhow::Result<()> { + let mut server = Server::bind(addr, latency).await?; + tracing::info!(%addr, %name, "SRT listening (import)"); + notify_ready(); + + while let Some(request) = server.accept().await { + match request { + Request::Publish(publish) => { + let origin = origin.clone(); + let name = name.clone(); + tokio::spawn(async move { + if let Err(err) = publish.accept(&origin, &name).await { + tracing::warn!(%name, %err, "SRT ingest ended with error"); + } + }); + } + Request::Subscribe(subscribe) => { + tokio::spawn(async move { + let _ = subscribe.reject().await; + }); + } + _ => {} + } + } + + Ok(()) +} + +/// Serve SRT requests for `name` from the Origin; reject publishes (export). +pub async fn listen_export( + origin: moq_net::OriginConsumer, + addr: SocketAddr, + name: String, + latency: Duration, +) -> anyhow::Result<()> { + let mut server = Server::bind(addr, latency).await?; + tracing::info!(%addr, %name, "SRT listening (export)"); + notify_ready(); + + while let Some(request) = server.accept().await { + match request { + Request::Subscribe(subscribe) => { + let origin = origin.clone(); + let name = name.clone(); + tokio::spawn(async move { + if let Err(err) = subscribe.accept(&origin, &name).await { + tracing::warn!(%name, %err, "SRT request ended with error"); + } + }); + } + Request::Publish(publish) => { + tokio::spawn(async move { + let _ = publish.reject().await; + }); + } + _ => {} + } + } + + Ok(()) +} + +/// Dial a remote SRT server and pull its stream into the Origin under `name` (import). +pub async fn connect_import( + origin: moq_net::OriginProducer, + url: Url, + name: String, + latency: Duration, +) -> anyhow::Result<()> { + let (addr, resource) = parse_url(&url).await?; + tracing::info!(%url, %name, "SRT client pulling"); + notify_ready(); + + Ok(moq_srt::dial::pull(addr, &resource, latency, &origin, &name).await?) +} + +/// Push a broadcast from the Origin to a remote SRT server (export). +pub async fn connect_export( + origin: moq_net::OriginConsumer, + url: Url, + name: String, + latency: Duration, +) -> anyhow::Result<()> { + let (addr, resource) = parse_url(&url).await?; + tracing::info!(%url, %name, "SRT client pushing"); + notify_ready(); + + Ok(moq_srt::dial::publish(addr, &resource, latency, &origin, &name).await?) +} + +/// Parse `srt://host:port?streamid=` into a resolved address and resource. +/// The resource falls back to the URL path when `streamid` is absent. +async fn parse_url(url: &Url) -> anyhow::Result<(SocketAddr, String)> { + anyhow::ensure!(url.scheme() == "srt", "srt url must use the srt scheme: {url}"); + + let host = url.host_str().with_context(|| format!("srt url missing host: {url}"))?; + let port = url.port().context("srt url must include a port: srt://host:port")?; + let addr = tokio::net::lookup_host((host, port)) + .await? + .next() + .with_context(|| format!("could not resolve {host}:{port}"))?; + + let resource = url + .query_pairs() + .find(|(key, _)| key == "streamid") + .map(|(_, value)| value.into_owned()) + .unwrap_or_else(|| url.path().trim_matches('/').to_string()); + anyhow::ensure!(!resource.is_empty(), "srt url must include a streamid or path"); + + Ok((addr, resource)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Numeric hosts resolve without touching DNS, so these stay offline. + async fn parse(url: &str) -> anyhow::Result<(SocketAddr, String)> { + parse_url(&Url::parse(url).unwrap()).await + } + + #[tokio::test] + async fn resource_from_streamid() { + let (addr, resource) = parse("srt://127.0.0.1:9000?streamid=live/cam").await.unwrap(); + assert_eq!(addr.port(), 9000); + assert_eq!(resource, "live/cam"); + } + + #[tokio::test] + async fn resource_from_path() { + let (_, resource) = parse("srt://127.0.0.1:9000/live/cam").await.unwrap(); + assert_eq!(resource, "live/cam"); + } + + #[tokio::test] + async fn rejects_non_srt_scheme() { + assert!(parse("udp://127.0.0.1:9000").await.is_err()); + } + + #[tokio::test] + async fn requires_port_and_resource() { + assert!(parse("srt://127.0.0.1").await.is_err()); + assert!(parse("srt://127.0.0.1:9000").await.is_err()); + } +} diff --git a/rs/moq-cli/src/subscribe.rs b/rs/moq-cli/src/subscribe.rs index be494eeaaf..57284f4e35 100644 --- a/rs/moq-cli/src/subscribe.rs +++ b/rs/moq-cli/src/subscribe.rs @@ -3,19 +3,24 @@ use std::time::Duration; use clap::ValueEnum; use hang::catalog::{AudioCodecKind, VideoCodecKind}; use hang::moq_net; -use moq_mux::catalog::{self, CatalogFormat, FilterAudio, FilterVideo, Stream, TargetAudio, TargetVideo}; +use moq_mux::catalog::{self, CatalogFormat, Stream}; +use moq_mux::select; use tokio::io::AsyncWriteExt; -#[derive(ValueEnum, Clone, Copy)] +/// Container format written to stdout on the export (sink) side. +#[derive(Clone, Copy)] pub enum SubscribeFormat { + /// Fragmented MP4 (CMAF). Fmp4, + /// Matroska / WebM. Mkv, /// H.264 Annex-B elementary stream (no container). H264, /// H.265 Annex-B elementary stream (no container). H265, - /// MPEG-TS (transport stream) container. + /// MPEG-TS (transport stream). Ts, + /// FLV (Flash Video / RTMP). Flv, } @@ -24,6 +29,8 @@ pub enum SubscribeFormat { #[derive(ValueEnum, Clone, Copy)] pub enum CatalogFormatArg { Hang, + #[value(name = "hangz")] + HangZ, Msf, } @@ -31,6 +38,7 @@ impl From for CatalogFormat { fn from(format: CatalogFormatArg) -> Self { match format { CatalogFormatArg::Hang => Self::Hang, + CatalogFormatArg::HangZ => Self::HangZ, CatalogFormatArg::Msf => Self::Msf, } } @@ -74,31 +82,10 @@ impl From for AudioCodecKind { } } -#[derive(clap::Args, Clone)] -pub struct SubscribeArgs { - /// The format to write to stdout. - #[arg(long)] - pub format: SubscribeFormat, - - /// Maximum latency before skipping groups (e.g. `500ms`, `1s`). - #[arg(long, default_value = "500ms", value_parser = humantime::parse_duration)] - pub max_latency: Duration, - - /// Cap the output fragment duration (e.g. `2s`, `500ms`). - /// - /// By default a fragment covers one GOP (rolled over on video keyframes). - /// Setting this caps each fragment to roughly the given duration. - /// The cap applies in addition to GOP rollover. - #[arg(long, value_parser = humantime::parse_duration)] - pub fragment_duration: Option, - - /// Catalog format to subscribe to for track discovery. - /// - /// When omitted, the format is auto-detected from the broadcast name suffix - /// (`.hang` -> hang, `.msf` -> msf), falling back to hang. - #[arg(long)] - pub catalog: Option, - +/// Rendition selection flags for the stdout container sinks. With no flags set, +/// every rendition is kept. +#[derive(clap::Args, Clone, Default)] +pub struct SelectArgs { /// Pick the video rendition with this exact name. #[arg(long)] pub video_name: Option, @@ -107,22 +94,6 @@ pub struct SubscribeArgs { #[arg(long)] pub video_codec: Option, - /// Prefer a video rendition no wider than this (px). - #[arg(long)] - pub video_width_max: Option, - - /// Prefer a video rendition no taller than this (px). - #[arg(long)] - pub video_height_max: Option, - - /// Prefer a video rendition with at most this many pixels (`coded_width * coded_height`). - #[arg(long)] - pub video_pixels_max: Option, - - /// Prefer a video rendition under this bitrate (bits per second). - #[arg(long)] - pub video_bitrate_max: Option, - /// Pick the audio rendition with this exact name. #[arg(long)] pub audio_name: Option, @@ -130,10 +101,25 @@ pub struct SubscribeArgs { /// Keep only audio renditions whose codec family matches. #[arg(long)] pub audio_codec: Option, +} - /// Prefer an audio rendition under this bitrate (bits per second). - #[arg(long)] - pub audio_bitrate_max: Option, +/// The resolved stdout export settings (built from the `export` flags + format). +#[derive(Clone)] +pub struct SubscribeArgs { + /// The format to write to stdout. + pub format: SubscribeFormat, + + /// Maximum latency before skipping groups. + pub max_latency: Duration, + + /// Cap the output fragment duration (default: one GOP). Applies to fmp4 / mkv. + pub fragment_duration: Option, + + /// Catalog format for track discovery (default: detect from the broadcast suffix). + pub catalog: Option, + + /// Rendition selection (name / codec) applied before export. + pub select: SelectArgs, } impl SubscribeArgs { @@ -146,8 +132,8 @@ impl SubscribeArgs { .unwrap_or_default() } - /// Codec implied by the output format. `--format h264` / `--format h265` - /// each force a single codec family; container formats leave it open. + /// Codec implied by the output format. The `h264` / `h265` sinks each force + /// a single codec family; container formats leave it open. fn format_codec(&self) -> Option { match self.format { SubscribeFormat::H264 => Some(VideoCodecKind::H264), @@ -156,63 +142,46 @@ impl SubscribeArgs { } } - /// Build a video filter from the parsed flags, plus any codec defaulted by - /// the chosen output format (e.g. `--format h264` implies `codec = H264`). + /// Build the rendition selection from the flags, plus any codec forced by + /// the output format (the `h264` sink implies `codec = H264`). /// - /// Errors if `--video-codec` contradicts the format-implied codec — fail + /// Errors if `--video-codec` contradicts the format-implied codec, failing /// fast in the CLI rather than later in the exporter. - fn filter_video(&self) -> anyhow::Result> { - let user_codec = self.video_codec.map(VideoCodecKind::from); + fn selection(&self) -> anyhow::Result { + let user_codec = self.select.video_codec.map(VideoCodecKind::from); let codec = match (self.format_codec(), user_codec) { (Some(fmt), Some(user)) if fmt != user => { anyhow::bail!( - "--format implies video codec {fmt:?}, but --video-codec {user:?} was passed; \ + "the output format implies video codec {fmt:?}, but --video-codec {user:?} was passed; \ remove --video-codec or pick a matching format" ); } (Some(fmt), _) => Some(fmt), (None, user) => user, }; - if self.video_name.is_none() && codec.is_none() { - return Ok(None); - } - Ok(Some(FilterVideo { - name: self.video_name.clone(), - codec, - })) - } - fn filter_audio(&self) -> Option { - if self.audio_name.is_none() && self.audio_codec.is_none() { - return None; + // Both roles stay opted in; criteria-free roles keep every rendition. + let mut video = select::Video::default(); + if let Some(name) = &self.select.video_name { + video = video.name(name); + } + if let Some(codec) = codec { + video = video.codec(codec); } - Some(FilterAudio { - name: self.audio_name.clone(), - codec: self.audio_codec.map(Into::into), - }) - } - fn target_video(&self) -> Option { - if self.video_width_max.is_none() - && self.video_height_max.is_none() - && self.video_pixels_max.is_none() - && self.video_bitrate_max.is_none() - { - return None; + let mut audio = select::Audio::default(); + if let Some(name) = &self.select.audio_name { + audio = audio.name(name); + } + if let Some(codec) = self.select.audio_codec { + audio = audio.codec(codec.into()); } - Some(TargetVideo { - width: self.video_width_max, - height: self.video_height_max, - pixels: self.video_pixels_max, - bitrate: self.video_bitrate_max, - }) - } - fn target_audio(&self) -> Option { - self.audio_bitrate_max.map(|b| TargetAudio { bitrate: Some(b) }) + Ok(select::Broadcast::default().video(video).audio(audio)) } } +/// Exports one broadcast from the Origin to stdout in the requested format. pub struct Subscribe { broadcast: moq_net::BroadcastConsumer, catalog: CatalogFormat, @@ -220,6 +189,7 @@ pub struct Subscribe { } impl Subscribe { + /// Wrap the broadcast + resolved settings; [`run`](Self::run) drives it. pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: CatalogFormat, args: SubscribeArgs) -> Self { Self { broadcast, @@ -228,21 +198,14 @@ impl Subscribe { } } - /// Build the catalog stream from the configured filter/target flags. - async fn stream(&self) -> anyhow::Result>> { + /// Build the catalog stream, narrowed by the rendition selection flags. The + /// catalog source honors the requested format (e.g. compressed `HangZ` or `Msf`). + async fn stream(&self) -> anyhow::Result> { let consumer = catalog::Consumer::new(&self.broadcast, self.catalog).await?; - - let mut filter = consumer.filter(); - filter.set_video(self.args.filter_video()?); - filter.set_audio(self.args.filter_audio()); - - let mut target = filter.target(); - target.set_video(self.args.target_video()); - target.set_audio(self.args.target_audio()); - - Ok(target) + Ok(consumer.select(self.args.selection()?)) } + /// Write the broadcast to stdout until it ends. pub async fn run(self) -> anyhow::Result<()> { match self.args.format { SubscribeFormat::Fmp4 => self.run_fmp4().await, @@ -257,8 +220,10 @@ impl Subscribe { async fn run_fmp4(self) -> anyhow::Result<()> { let mut stdout = tokio::io::stdout(); + // Fmp4 builds the merged init segment from the first catalog snapshot, then + // yields moof+mdat fragments in timestamp order across tracks. let stream = self.stream().await?; - let mut fmp4 = moq_mux::container::fmp4::Export::new(self.broadcast.clone(), stream) + let mut fmp4 = moq_mux::container::fmp4::Export::new(self.broadcast, stream) .with_latency(self.args.max_latency) .with_fragment_duration(self.args.fragment_duration); @@ -273,8 +238,11 @@ impl Subscribe { async fn run_mkv(self) -> anyhow::Result<()> { let mut stdout = tokio::io::stdout(); + // Mkv writes EBML + an unknown-size Segment header, then per-fragment + // Cluster elements. Avc3/Hev1 sources are transcoded to avc1/hvc1 + // shape internally (synthesizing avcC/hvcC from inline parameter sets). let stream = self.stream().await?; - let mut mkv = moq_mux::container::mkv::Export::new(self.broadcast.clone(), stream) + let mut mkv = moq_mux::container::mkv::Export::new(self.broadcast, stream) .with_latency(self.args.max_latency) .with_fragment_duration(self.args.fragment_duration); @@ -290,8 +258,7 @@ impl Subscribe { let mut stdout = tokio::io::stdout(); let stream = self.stream().await?; - let mut h264 = - moq_mux::codec::h264::Export::new(self.broadcast.clone(), stream).with_latency(self.args.max_latency); + let mut h264 = moq_mux::codec::h264::Export::new(self.broadcast, stream).with_latency(self.args.max_latency); while let Some(chunk) = h264.next().await? { stdout.write_all(&chunk).await?; @@ -305,8 +272,7 @@ impl Subscribe { let mut stdout = tokio::io::stdout(); let stream = self.stream().await?; - let mut h265 = - moq_mux::codec::h265::Export::new(self.broadcast.clone(), stream).with_latency(self.args.max_latency); + let mut h265 = moq_mux::codec::h265::Export::new(self.broadcast, stream).with_latency(self.args.max_latency); while let Some(chunk) = h265.next().await? { stdout.write_all(&chunk).await?; diff --git a/rs/moq-cli/src/web.rs b/rs/moq-cli/src/web.rs index 43e2ec962d..e58bdfa3bf 100644 --- a/rs/moq-cli/src/web.rs +++ b/rs/moq-cli/src/web.rs @@ -1,19 +1,66 @@ use anyhow::Context; use axum::handler::HandlerWithoutStateExt; -use axum::http::StatusCode; +use axum::http::{HeaderValue, Method, StatusCode}; use axum::response::IntoResponse; -use axum::{Router, http::Method, routing::get}; -use std::path::PathBuf; +use axum::{Router, routing::get}; use std::sync::{Arc, RwLock}; use tower_http::cors::{Any, CorsLayer}; -use tower_http::services::ServeDir; -// Initialize the HTTP server (but don't serve yet). -pub async fn run_web( - bind: &str, - tls_info: Arc>, - public: Option, +/// Browser CORS policy for HTTP gateway listeners. +#[derive(clap::Args, Clone, Default)] +pub struct Cors { + /// Browser origin allowed to call this listener. Repeat to allow multiple. + #[arg(long = "cors-origin", value_name = "ORIGIN", value_parser = parse_origin)] + pub origin: Vec, +} + +impl Cors { + /// Build a CORS layer for the given listener methods. + pub fn layer(&self, methods: [Method; N]) -> anyhow::Result { + let layer = CorsLayer::new().allow_methods(methods).allow_headers(Any); + let wildcard = HeaderValue::from_static("*"); + + Ok(match self.origin.as_slice() { + [] => layer, + [origin] if origin == wildcard => layer.allow_origin(Any), + origins => { + anyhow::ensure!( + !origins.contains(&wildcard), + "`--cors-origin *` cannot be combined with specific origins" + ); + layer.allow_origin(origins.to_vec()) + } + }) + } +} + +fn parse_origin(origin: &str) -> Result { + origin.parse() +} + +/// Serve an axum router over TCP, optionally terminating TLS. Used by the HLS +/// and WebRTC (WHIP/WHEP) HTTP endpoints. +pub async fn serve( + listener: std::net::TcpListener, + app: Router, + tls: Option>, ) -> anyhow::Result<()> { + let service = app.into_make_service(); + match tls { + Some(config) => { + let config = axum_server::tls_rustls::RustlsConfig::from_config(config); + axum_server::from_tcp_rustls(listener, config)?.serve(service).await?; + } + None => { + axum_server::from_tcp(listener)?.serve(service).await?; + } + } + Ok(()) +} + +/// Serve the `/certificate.sha256` self-signed fingerprint over HTTP, so an +/// `http://` client can pin a `--server-bind` server's generated cert. +pub async fn run_web(bind: &str, tls_info: Arc>) -> anyhow::Result<()> { let listen = tokio::net::lookup_host(bind) .await .context("invalid listen address")? @@ -36,20 +83,10 @@ pub async fn run_web( .clone() }; - let mut app = Router::new() + let app = Router::new() .route("/certificate.sha256", get(fingerprint_handler)) - .layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])); - - // If a public directory is provided, serve it. - // We use this for local development to serve the index.html file and friends. - if let Some(public) = public.as_ref() { - tracing::info!(public = %public.display(), "serving directory"); - - let public = ServeDir::new(public).not_found_service(handle_404.into_service()); - app = app.fallback_service(public); - } else { - app = app.fallback_service(handle_404.into_service()); - } + .layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])) + .fallback_service(handle_404.into_service()); // Dual-stack so the cert endpoint answers over IPv4 too, even on Windows // where `[::]` is IPv6-only by default. @@ -59,3 +96,36 @@ pub async fn run_web( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cors_origin_defaults_to_no_browser_origin() { + let cors = Cors::default(); + + assert!(cors.layer([Method::GET]).is_ok()); + } + + #[test] + fn cors_origin_allows_specific_allowlist() { + let cors = Cors { + origin: vec![HeaderValue::from_static("https://example.com")], + }; + + assert!(cors.layer([Method::GET]).is_ok()); + } + + #[test] + fn cors_origin_rejects_wildcard_with_allowlist() { + let cors = Cors { + origin: vec![ + HeaderValue::from_static("*"), + HeaderValue::from_static("https://example.com"), + ], + }; + + assert!(cors.layer([Method::GET]).is_err()); + } +} diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index d12e3c8646..15e8db71ab 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.25](https://github.com/moq-dev/moq/compare/moq-ffi-v0.2.24...moq-ffi-v0.2.25) - 2026-06-30 + +### Other + +- API cleanup before the semver bump ([#1941](https://github.com/moq-dev/moq/pull/1941)) +- Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) ([#1918](https://github.com/moq-dev/moq/pull/1918)) + ## [0.2.23](https://github.com/moq-dev/moq/compare/moq-ffi-v0.2.22...moq-ffi-v0.2.23) - 2026-06-23 ### Added diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 0e7d3a5dc6..efa8e62f84 100644 --- a/rs/moq-ffi/Cargo.toml +++ b/rs/moq-ffi/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.24" +version = "0.2.25" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-ffi/src/android.rs b/rs/moq-ffi/src/android.rs new file mode 100644 index 0000000000..47e1a980bf --- /dev/null +++ b/rs/moq-ffi/src/android.rs @@ -0,0 +1,58 @@ +//! Android JNI bootstrap. +//! +//! Auto-initializes moq-native's platform TLS verifier when the JVM loads this +//! library, so Android apps verify against the OS trust store without any +//! Kotlin/Java setup. Best-effort: if the application `Context` can't be found +//! (e.g. loaded too early, or in a non-app process), moq-native falls back to +//! the bundled Mozilla roots. + +use std::ffi::c_void; + +use moq_native::jni::sys::{JNI_VERSION_1_6, jint}; +use moq_native::jni::{JNIEnv, JavaVM}; + +/// Called by the JVM on `System.loadLibrary("moq_ffi")`. The name is fixed by +/// the JNI spec, so it can't follow Rust's snake_case convention. +#[unsafe(no_mangle)] +#[allow(non_snake_case)] +pub extern "system" fn JNI_OnLoad(vm: JavaVM, _reserved: *mut c_void) -> jint { + if let Err(err) = init_platform_tls(&vm) { + tracing::warn!(%err, "could not auto-initialize the Android platform TLS verifier; using bundled roots"); + } + JNI_VERSION_1_6 +} + +/// Attach the loader thread, run the init, and clear any pending Java exception +/// a failed JNI call may have left behind. +fn init_platform_tls(vm: &JavaVM) -> Result<(), Box> { + let mut env = vm.attach_current_thread()?; + let result = discover_context_and_init(&mut env); + if result.is_err() { + // Clear here (before JNI_OnLoad logs and returns) so a pending exception + // can't surface as a System.loadLibrary failure or abort the next JNI + // call; the bundled-roots fallback already covers the init failure. + let _ = env.exception_clear(); + } + result +} + +/// Reflectively fetch the application `Context` and hand it to moq-native. +fn discover_context_and_init(env: &mut JNIEnv) -> Result<(), Box> { + // The app Context isn't passed to native code, so fetch it from + // android.app.ActivityThread.currentApplication() (a long-stable internal API). + let app = env + .call_static_method( + "android/app/ActivityThread", + "currentApplication", + "()Landroid/app/Application;", + &[], + )? + .l()?; + + if app.is_null() { + return Err("ActivityThread.currentApplication() returned null".into()); + } + + moq_native::tls::init_android(env, app)?; + Ok(()) +} diff --git a/rs/moq-ffi/src/lib.rs b/rs/moq-ffi/src/lib.rs index 8a29c21930..038fef99e4 100644 --- a/rs/moq-ffi/src/lib.rs +++ b/rs/moq-ffi/src/lib.rs @@ -3,6 +3,8 @@ //! Provides a Kotlin/Swift-compatible API for real-time pub/sub over QUIC. //! Uses async UniFFI objects instead of callbacks for a native async experience. +#[cfg(target_os = "android")] +mod android; pub mod audio; pub mod consumer; pub mod error; diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index fcfe7666c7..cb53d0cafa 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -97,6 +97,22 @@ enum StreamDecoder { Container(moq_mux::import::ContainerStream), } +impl StreamDecoder { + fn decode(&mut self, data: &[u8]) -> moq_mux::Result<()> { + match self { + Self::Track(t) => t.decode(data), + Self::Container(c) => c.decode(data), + } + } + + fn finish(&mut self) -> moq_mux::Result<()> { + match self { + Self::Track(t) => t.finish(), + Self::Container(c) => c.finish(), + } + } +} + struct MediaStreamProducer { // The importer buffers any partial trailing frame internally, so callers can // write arbitrary chunks without retaining a remainder here. @@ -187,7 +203,8 @@ impl MoqBroadcastProducer { pub fn new() -> Result, MoqError> { let _guard = crate::ffi::RUNTIME.enter(); let mut broadcast = moq_net::BroadcastInfo::new().produce(); - let catalog = moq_mux::catalog::Producer::new_extra(&mut broadcast)?; + let catalog = + moq_mux::catalog::Producer::with_catalog(&mut broadcast, moq_mux::catalog::hang::Catalog::default())?; Ok(Arc::new(Self { state: std::sync::Mutex::new(Some(BroadcastProducer { broadcast, catalog })), })) @@ -202,7 +219,7 @@ impl MoqBroadcastProducer { pub fn set_catalog_section(&self, name: String, json: String) -> Result<(), MoqError> { let _guard = crate::ffi::RUNTIME.enter(); let value: serde_json::Value = serde_json::from_str(&json)?; - self.with_state(|state| Ok(state.catalog.set_section(name, value)?)) + self.with_state(|state| Ok(state.catalog.lock().set_section(name, value)?)) } /// Remove a top-level application catalog section by name. @@ -211,7 +228,7 @@ impl MoqBroadcastProducer { pub fn remove_catalog_section(&self, name: String) -> Result<(), MoqError> { let _guard = crate::ffi::RUNTIME.enter(); self.with_state(|state| { - state.catalog.remove_section(&name); + state.catalog.lock().remove_section(&name); Ok(()) }) } @@ -310,10 +327,13 @@ impl MoqBroadcastProducer { let request = broadcast .reserve_track(name) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; - StreamDecoder::Track(Box::new( - moq_mux::import::TrackStream::new(request, state.catalog.clone(), &format) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?, - )) + match moq_mux::import::TrackStream::new(request, state.catalog.clone(), &format) { + Ok(import) => StreamDecoder::Track(Box::new(import)), + Err(moq_mux::Error::UnknownFormat(_)) => { + return Err(MoqError::Codec(format!("unknown stream format: {format}"))); + } + Err(err) => return Err(MoqError::Codec(format!("init failed: {err}"))), + } } Err(err) => return Err(MoqError::Codec(format!("init failed: {err}"))), }; @@ -661,11 +681,10 @@ impl MoqMediaStreamProducer { let mut guard = self.inner.lock().unwrap(); let media = guard.as_mut().ok_or_else(|| MoqError::Closed)?; - match &mut media.decoder { - StreamDecoder::Track(decoder) => decoder.decode(&payload), - StreamDecoder::Container(decoder) => decoder.decode(&payload), - } - .map_err(|err| MoqError::Codec(format!("decode failed: {err}")))?; + media + .decoder + .decode(&payload) + .map_err(|err| MoqError::Codec(format!("decode failed: {err}")))?; Ok(()) } @@ -678,11 +697,10 @@ impl MoqMediaStreamProducer { let _guard = crate::ffi::RUNTIME.enter(); let mut guard = self.inner.lock().unwrap(); let mut media = guard.take().ok_or_else(|| MoqError::Closed)?; - match &mut media.decoder { - StreamDecoder::Track(decoder) => decoder.finish(), - StreamDecoder::Container(decoder) => decoder.finish(), - } - .map_err(|err| MoqError::Codec(format!("finish failed: {err}")))?; + media + .decoder + .finish() + .map_err(|err| MoqError::Codec(format!("finish failed: {err}")))?; Ok(()) } } diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 2c66899f03..f2be624b01 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -65,6 +65,21 @@ mod tests { MoqError::Forbidden )); } + + #[test] + fn sets_tls_system_roots() { + let client = MoqClient::new(); + + client.set_tls_system_roots(true); + { + let state = client.task.lock().expect("client state should be available"); + assert_eq!(state.config.tls.system_roots, Some(true)); + } + + client.set_tls_system_roots(false); + let state = client.task.lock().expect("client state should be available"); + assert_eq!(state.config.tls.system_roots, Some(false)); + } } #[derive(uniffi::Object)] @@ -104,6 +119,17 @@ impl MoqClient { } } + /// Configure whether to also trust the platform's native root certificates. + /// + /// By default, system roots are trusted only when no custom roots are configured. + /// Set this to `true` to trust system roots in addition to roots from + /// `set_tls_roots`, or `false` to trust only custom roots. + pub fn set_tls_system_roots(&self, system_roots: bool) { + if let Some(mut state) = self.task.lock() { + state.config.tls.system_roots = Some(system_roots); + } + } + /// Pin the peer to a certificate with one of these SHA-256 fingerprints, encoded as hex. /// /// This is the native equivalent of the browser's WebTransport `serverCertificateHashes` diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index 7b423cd3b7..c170248132 100644 --- a/rs/moq-gst/CHANGELOG.md +++ b/rs/moq-gst/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.9](https://github.com/moq-dev/moq/compare/moq-gst-v0.2.8...moq-gst-v0.2.9) - 2026-06-30 + +### Other + +- Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) ([#1918](https://github.com/moq-dev/moq/pull/1918)) +- moqsink on a bare Element with direct (no-channel) writes ([#1893](https://github.com/moq-dev/moq/pull/1893)) + ## [0.2.8](https://github.com/moq-dev/moq/compare/moq-gst-v0.2.7...moq-gst-v0.2.8) - 2026-06-23 ### Fixed diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index ac754d44e5..ba0d747947 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/kixelated/moq" license = "MIT OR Apache-2.0" -version = "0.2.8" +version = "0.2.9" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index aebb765b4a..34972b7aab 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -232,6 +232,13 @@ impl ElementImpl for MoqSink { .field("stream-format", "raw") .build(), ); + // MP3 (MPEG-1/2 Layer III). The frame header carries the config in band. + caps.merge( + gst::Caps::builder("audio/mpeg") + .field("mpegversion", gst::List::new([1i32, 2i32])) + .field("layer", 3i32) + .build(), + ); caps.merge(gst::Caps::builder("audio/x-opus").build()); let sink = diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index bd3e245c2c..e1d9e2b344 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -100,6 +100,26 @@ impl Pad { "video/x-av1" => Self::reserve(&mut broadcast, catalog, ".av01", "av01", &[])?, "video/x-vp8" => Self::reserve(&mut broadcast, catalog, ".vp8", "vp8", &[])?, "video/x-vp9" => Self::reserve(&mut broadcast, catalog, ".vp9", "vp9", &[])?, + // MP3: no config blob to parse (the config lives in each frame header), so the importer is + // built straight from the caps rate/channels. Keyed on `layer == 3`, which positively + // identifies Layer III: AAC (`audio/mpeg`, no layer field) and MP2 (`layer=2`) fall through + // to the AAC arm below. + "audio/mpeg" if structure.get::("layer").ok() == Some(3) => { + let rate: i32 = structure.get("rate").context("MP3 caps missing rate")?; + let channels: i32 = structure.get("channels").context("MP3 caps missing channels")?; + ensure!(rate > 0, "MP3 caps has non-positive sample rate {rate}"); + ensure!(channels > 0, "MP3 caps has non-positive channel count {channels}"); + let config = moq_mux::codec::mp3::Config { + sample_rate: rate as u32, + channel_count: channels as u32, + }; + // MP3 builds its config from caps, so like Opus it constructs the codec importer + // directly and lifts it into a `Track` via `.into()`. + let name = broadcast.unique_name(".mp3"); + let request = broadcast.reserve_track(name)?; + let producer = request.accept(moq_net::TrackInfo::default().with_timescale(hang::container::TIMESCALE)); + moq_mux::codec::mp3::Import::new(producer, catalog, config)?.into() + } "audio/mpeg" => { // AAC: the AudioSpecificConfig rides in caps as codec_data, not in the bitstream. let codec_data = structure diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 36da757b62..d14928aaf7 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -688,6 +688,12 @@ fn audio_caps(config: &hang::catalog::AudioConfig) -> Result { } builder.build() } + hang::catalog::AudioCodec::Mp3 => gst::Caps::builder("audio/mpeg") + .field("mpegversion", 1) + .field("layer", 3) + .field("rate", config.sample_rate) + .field("channels", config.channel_count) + .build(), other => bail!("unsupported audio codec: {other:?}"), }; Ok(caps) diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md new file mode 100644 index 0000000000..43352f2425 --- /dev/null +++ b/rs/moq-hls/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.1](https://github.com/moq-dev/moq/releases/tag/moq-hls-v0.0.1) - 2026-06-30 + +### Other + +- preserve discontinuity sequence through fMP4 import ([#1945](https://github.com/moq-dev/moq/pull/1945)) +- unify rendition selection behind select::Broadcast +- [codex] Route HLS CLI import through moq-hls ([#1939](https://github.com/moq-dev/moq/pull/1939)) +- [codex] Backport moq-hls to main ([#1924](https://github.com/moq-dev/moq/pull/1924)) diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 7423a2614a..0568ef3b0b 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -15,50 +15,27 @@ categories = ["multimedia", "network-programming", "web-programming"] [lib] doctest = false -[[bin]] -name = "moq-hls" -path = "bin/moq-hls.rs" -doc = false -# The binary (and the HTTP export server) need the `server` feature; the library -# can be depended on with `default-features = false` for import only (e.g. moq-cli). -required-features = ["server"] - [features] -default = ["server", "iroh", "noq", "websocket"] -# HTTP export server + the moq-hls binary. Pulls in axum / moq-native / clap. -server = ["dep:axum", "dep:axum-server", "dep:clap", "dep:humantime", "dep:moq-native", "dep:rustls", "dep:sd-notify", "dep:tower-http"] -iroh = ["server", "moq-native/iroh"] -noq = ["server", "moq-native/noq"] -quinn = ["server", "moq-native/quinn"] -quiche = ["server", "moq-native/quiche"] -websocket = ["server", "moq-native/websocket"] +default = ["server"] +server = ["dep:axum"] [dependencies] # Always required (import + export library). anyhow = { version = "1", features = ["backtrace"] } -# Only needed by the HTTP export server / binary (gated by `server`). +# Only needed by the HTTP export server (gated by `server`). axum = { version = "0.8", features = ["tokio"], optional = true } -axum-server = { version = "0.8", features = ["tls-rustls"], optional = true } bytes = "1" -clap = { version = "4", features = ["derive"], optional = true } hang = { workspace = true } -humantime = { version = "2.3", optional = true } kio = { workspace = true } m3u8-rs = "6" moq-mux = { workspace = true } -moq-native = { workspace = true, default-features = false, features = ["aws-lc-rs"], optional = true } moq-net = { workspace = true } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] } -rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false, optional = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "gzip"] } thiserror = "2" tokio = { workspace = true, features = ["full"] } -tower-http = { version = "0.7", features = ["cors"], optional = true } tracing = "0.1" url = "2" -[target.'cfg(unix)'.dependencies] -sd-notify = { version = "0.5", optional = true } - [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/rs/moq-hls/bin/moq-hls.rs b/rs/moq-hls/bin/moq-hls.rs deleted file mode 100644 index 2ca66550c7..0000000000 --- a/rs/moq-hls/bin/moq-hls.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! `moq-hls` binary. -//! -//! Two subcommands under shared relay/client globals: -//! -//! - `export` -- subscribe to MoQ broadcasts and serve HLS + LL-HLS over HTTP -//! (an HTTP *server* that *subscribes*; the WHEP-server analogue in `moq-rtc`). -//! - `import` -- pull a remote HLS playlist and publish it into MoQ (an HTTP -//! *client* that *publishes*; the WHEP-client analogue in `moq-rtc`). -//! -//! HLS isn't a symmetric push/pull protocol like WHIP/WHEP, so these are -//! explicit subcommands rather than a `server`/`client` x `publish`/`subscribe` -//! matrix. - -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Context; -use axum::Router; -use clap::{Parser, Subcommand}; -use moq_hls::Server; -use tower_http::cors::{Any, CorsLayer}; -use url::Url; - -#[derive(Parser, Clone)] -#[command(version)] -struct Cli { - #[command(flatten)] - log: moq_native::Log, - - /// MoQ client configuration for dialing the upstream relay. - #[command(flatten)] - moq_client: moq_native::ClientConfig, - - /// URL of the upstream MoQ relay to publish into (import) or read from (export). - #[arg(long, env = "MOQ_HLS_RELAY")] - relay: Url, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Clone)] -enum Command { - /// Serve HLS / LL-HLS over HTTP from MoQ broadcasts (path-based, multi-broadcast). - Export { - /// HTTP listener for the HLS endpoints. - #[arg(long, env = "MOQ_HLS_LISTEN", default_value = "[::]:8089")] - listen: SocketAddr, - - /// TLS certificates, keys, self-signed generation, and optional mTLS roots. - /// Serve HTTPS by setting `--tls-cert`/`--tls-key` or `--tls-generate`. - /// Most players require HTTPS. - #[command(flatten)] - tls: moq_native::tls::Server, - - /// LL-HLS part target duration (also caps the exporter's fragment duration). - #[arg(long, env = "MOQ_HLS_PART_TARGET", default_value = "500ms", value_parser = humantime::parse_duration)] - part_target: Duration, - - /// Minimum duration of media kept in each rendition's sliding window. - #[arg(long, env = "MOQ_HLS_WINDOW", default_value = "16s", value_parser = humantime::parse_duration)] - window: Duration, - }, - /// Pull a remote HLS master/media playlist and publish it into MoQ. - Import { - /// Broadcast name to publish on the relay. - #[arg(long, alias = "name", env = "MOQ_HLS_BROADCAST")] - broadcast: String, - - /// Remote HLS playlist URL (http/https) or local file path. - #[arg(long, env = "MOQ_HLS_PLAYLIST")] - playlist: String, - }, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .expect("failed to install default crypto provider"); - - let Cli { - log, - moq_client, - relay, - command, - } = Cli::parse(); - log.init()?; - - let client = moq_client.init().context("failed to init moq client")?; - - match command { - Command::Export { - listen, - tls, - part_target, - window, - } => { - let subscriber = moq_net::Origin::random().produce(); - let subscriber_consumer = subscriber.consume(); - let reconnect = client.with_subscriber(subscriber).reconnect(relay.clone()); - - let config = moq_hls::export::Config { - part_target, - window, - ..Default::default() - }; - let server = Server::new(subscriber_consumer, config); - let app = server - .router() - .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any)); - - // Serve HTTPS only when a cert/key pair or self-signed generation is configured. - let tls = if tls.cert.is_empty() && tls.generate.is_empty() { - None - } else { - let alpn = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - Some(tls.server_config(alpn).context("failed to build TLS config")?) - }; - - tracing::info!(%relay, %listen, "moq-hls serving HLS"); - - #[cfg(unix)] - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); - - tokio::select! { - res = serve(app, listen, tls) => res, - res = reconnect.closed() => res.map_err(Into::into), - _ = tokio::signal::ctrl_c() => Ok(()), - } - } - Command::Import { broadcast, playlist } => { - let publisher = moq_net::Origin::random().produce(); - let reconnect = client.with_publisher(&publisher).reconnect(relay.clone()); - - let mut producer = moq_net::BroadcastInfo::new().produce(); - let consumer = producer.consume(); - let _publish = publisher - .publish_broadcast(&broadcast, &consumer) - .context("failed to publish broadcast")?; - - let catalog = moq_mux::catalog::Producer::new(&mut producer).context("failed to create catalog")?; - let mut importer = moq_hls::import::Import::new(producer, catalog, moq_hls::import::Config::new(playlist))?; - - tracing::info!(%relay, %broadcast, "moq-hls importing HLS"); - - #[cfg(unix)] - let _ = sd_notify::notify(&[sd_notify::NotifyState::Ready]); - - tokio::select! { - res = async { - importer.init().await?; - importer.run().await - } => res.map_err(Into::into), - res = reconnect.closed() => res.map_err(Into::into), - _ = tokio::signal::ctrl_c() => Ok(()), - } - } - } -} - -async fn serve(app: Router, bind: SocketAddr, tls: Option>) -> anyhow::Result<()> { - let service = app.into_make_service(); - match tls { - Some(config) => { - let config = axum_server::tls_rustls::RustlsConfig::from_config(config); - axum_server::bind_rustls(bind, config).serve(service).await?; - } - None => { - axum_server::bind(bind).serve(service).await?; - } - } - Ok(()) -} diff --git a/rs/moq-hls/src/error.rs b/rs/moq-hls/src/error.rs index 44403e1773..5c3dfb6ce7 100644 --- a/rs/moq-hls/src/error.rs +++ b/rs/moq-hls/src/error.rs @@ -12,33 +12,52 @@ pub enum Error { #[error("mux: {0}")] Mux(#[from] moq_mux::Error), + /// The playlist argument looked like an HTTP(S) URL but failed to parse. #[error("invalid playlist URL")] InvalidPlaylistUrl, + /// The playlist argument was a local path that could not be made into a `file://` URL. #[error("invalid file path")] InvalidFilePath, + /// A `file://` URL could not be turned back into a filesystem path. #[error("invalid file URL")] InvalidFileUrl, + /// The fetched media playlist could not be parsed. #[error("failed to parse media playlist: {0}")] ParsePlaylist(String), + /// The master playlist contained no variant this gateway can import. #[error("no usable variants found in master playlist")] NoVariants, + /// A media playlist had no `EXT-X-MAP`, so there is no CMAF init segment. #[error("playlist missing EXT-X-MAP")] MissingMap, + /// A media segment had an empty URI. #[error("encountered segment with empty URI")] EmptySegmentUri, + /// An HLS media or discontinuity sequence was too large to pack into a MoQ group sequence. + #[error("HLS {kind} sequence {value} is too large to encode")] + SequenceOverflow { + /// Which sequence overflowed: `"media"` or `"discontinuity"`. + kind: &'static str, + /// The offending sequence value. + value: u64, + }, + + /// A playlist or segment URI could not be resolved against its base. #[error("url parse: {0}")] UrlParse(#[from] url::ParseError), + /// HTTP error while fetching a playlist or segment. #[error("reqwest: {0}")] Reqwest(std::sync::Arc), + /// I/O error while reading a local playlist or segment. #[error("io: {0}")] Io(std::sync::Arc), @@ -65,4 +84,5 @@ impl From for Error { } } +/// Convenience alias for results from the HLS gateway. pub type Result = std::result::Result; diff --git a/rs/moq-hls/src/export/master.rs b/rs/moq-hls/src/export/master.rs index 9e4ce4fd3d..005ee05e67 100644 --- a/rs/moq-hls/src/export/master.rs +++ b/rs/moq-hls/src/export/master.rs @@ -10,9 +10,13 @@ const AUDIO_GROUP: &str = "aud"; /// A video rendition entry for the master playlist. pub struct VideoVariant { + /// Rendition name (also its `/media.m3u8` path component). pub name: String, + /// `BANDWIDTH` attribute, in bits per second. pub bandwidth: u64, + /// Coded width for the `RESOLUTION` attribute, if known. pub width: Option, + /// Coded height for the `RESOLUTION` attribute, if known. pub height: Option, /// RFC 6381 codec string (e.g. `avc1.42c01f`). pub codec: String, @@ -20,8 +24,11 @@ pub struct VideoVariant { /// An audio rendition entry for the master playlist. pub struct AudioVariant { + /// Rendition name (also its `/media.m3u8` path component). pub name: String, + /// `BANDWIDTH` attribute, in bits per second. pub bandwidth: u64, + /// RFC 6381 codec string (e.g. `mp4a.40.2`). pub codec: String, } diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 6db5ed5798..f5c5bafae8 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -2,7 +2,7 @@ //! //! A [`Broadcaster`] watches one broadcast's catalog and, per rendition, runs a //! [`moq_mux::container::fmp4::Export`] narrowed to that single track (via -//! [`moq_mux::catalog::Filter`]) feeding a [`store::SegmentStore`]. The HTTP +//! [`moq_mux::catalog::Select`]) feeding a [`store::SegmentStore`]. The HTTP //! [`server`](crate::server) reads the stores to answer playlist and segment //! requests. @@ -22,6 +22,9 @@ use tokio::sync::watch; pub use playlist::render_media; pub use rendition::{Kind, Rendition}; +/// How long to wait before retrying the initial catalog subscription. +const CATALOG_RETRY: Duration = Duration::from_millis(250); + /// Export tuning shared across renditions. #[derive(Clone, Debug)] pub struct Config { @@ -169,11 +172,16 @@ impl Broadcaster { } async fn watch_catalog(broadcast: moq_net::BroadcastConsumer, config: Config, broadcaster: Arc) { - let mut consumer = match catalog::Consumer::<()>::new(&broadcast, CatalogFormat::Hang).await { - Ok(consumer) => consumer, - Err(err) => { - tracing::warn!(%err, "failed to subscribe to broadcast catalog"); - return; + let mut consumer = loop { + match catalog::Consumer::<()>::new(&broadcast, CatalogFormat::Hang).await { + Ok(consumer) => break consumer, + Err(err) => { + tracing::warn!(%err, "failed to subscribe to broadcast catalog, retrying"); + tokio::select! { + _ = tokio::time::sleep(CATALOG_RETRY) => {} + _ = kio::wait(|waiter| broadcast.poll_closed(waiter)) => return, + } + } } }; diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 8a6b96a225..aadb4ba912 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -3,8 +3,9 @@ use std::sync::Arc; use hang::catalog::{AudioConfig, VideoConfig}; -use moq_mux::catalog::{self, CatalogFormat, Filter, FilterAudio, FilterVideo}; +use moq_mux::catalog::{self, CatalogFormat, Stream}; use moq_mux::container::fmp4::Export; +use moq_mux::select; use tokio::sync::watch; use super::Config; @@ -15,26 +16,36 @@ use crate::Result; const DEFAULT_VIDEO_BITRATE: u64 = 2_000_000; const DEFAULT_AUDIO_BITRATE: u64 = 128_000; +/// Whether a rendition carries video or audio (drives the store's segmenting policy). #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Kind { + /// Video: a segment is a GOP, rolling on each independent fragment. Video, + /// Audio: segments roll on accumulated duration (no keyframes). Audio, } /// A single HLS rendition: its display metadata for the master playlist plus the /// segment/part store fed by a background exporter task. pub struct Rendition { + /// Rendition name (the catalog track name; also its URL path component). pub name: String, + /// Whether this rendition is video or audio. pub kind: Kind, + /// Advertised bitrate for the master playlist `BANDWIDTH` attribute. pub bandwidth: u64, + /// Coded width, for the master playlist `RESOLUTION` (video only). pub width: Option, + /// Coded height, for the master playlist `RESOLUTION` (video only). pub height: Option, /// RFC 6381 codec string for the master playlist `CODECS` attribute. pub codec: String, + /// The segment/part store fed by this rendition's exporter task. pub store: Arc, } impl Rendition { + /// Build a video rendition and spawn its exporter pump. pub fn video( name: String, config: &VideoConfig, @@ -42,12 +53,7 @@ impl Rendition { cfg: &Config, paused: watch::Receiver, ) -> Self { - let store = Arc::new(SegmentStore::new( - true, - cfg.part_target.as_secs_f64(), - cfg.audio_segment_target.as_secs_f64(), - cfg.window.as_secs_f64(), - )); + let store = Arc::new(SegmentStore::new(Kind::Video, cfg)); spawn_pump(broadcast, name.clone(), Kind::Video, store.clone(), cfg.clone(), paused); Self { name, @@ -60,6 +66,7 @@ impl Rendition { } } + /// Build an audio rendition and spawn its exporter pump. pub fn audio( name: String, config: &AudioConfig, @@ -67,12 +74,7 @@ impl Rendition { cfg: &Config, paused: watch::Receiver, ) -> Self { - let store = Arc::new(SegmentStore::new( - false, - cfg.part_target.as_secs_f64(), - cfg.audio_segment_target.as_secs_f64(), - cfg.window.as_secs_f64(), - )); + let store = Arc::new(SegmentStore::new(Kind::Audio, cfg)); spawn_pump(broadcast, name.clone(), Kind::Audio, store.clone(), cfg.clone(), paused); Self { name, @@ -112,25 +114,19 @@ async fn run_pump( mut paused: watch::Receiver, ) -> Result<()> { let consumer = catalog::Consumer::<()>::new(&broadcast, CatalogFormat::Hang).await?; - let mut filter = Filter::new(consumer); - // Narrow *both* axes to this rendition's name so the exporter sees exactly one - // track: the opposite axis can't hold a rendition with this name, so it empties. - filter.set_video(FilterVideo { - name: Some(name.to_string()), - ..Default::default() - }); - filter.set_audio(FilterAudio { - name: Some(name.to_string()), - ..Default::default() - }); - let _ = kind; // kind only drives the store policy; the exporter is codec-agnostic. + // Select this rendition's name on its own axis so the exporter sees exactly one track. + let selection = match kind { + Kind::Video => select::Broadcast::default().video(select::Video::default().name(name)), + Kind::Audio => select::Broadcast::default().audio(select::Audio::default().name(name)), + }; + let filtered = consumer.select(selection); // A handle for noticing the broadcast close even while paused; the `Export` // below takes its own clone for pulling fragments. let closed = broadcast.clone(); - let mut export = Export::new(broadcast, filter) + let mut export = Export::new(broadcast, filtered) .with_fragment_duration(cfg.part_target) .with_latency(cfg.latency); diff --git a/rs/moq-hls/src/export/store.rs b/rs/moq-hls/src/export/store.rs index 6a36a4384e..1375515563 100644 --- a/rs/moq-hls/src/export/store.rs +++ b/rs/moq-hls/src/export/store.rs @@ -12,6 +12,8 @@ use bytes::{Bytes, BytesMut}; use moq_mux::container::fmp4::Fragment; use tokio::sync::watch; +use super::{Config, Kind}; + /// One LL-HLS partial segment: a single CMAF moof+mdat fragment. #[derive(Clone)] struct Part { @@ -37,15 +39,21 @@ struct Segment { /// Lightweight per-part metadata for rendering a playlist (no bytes). pub struct PartMeta { + /// Part presentation duration, in seconds. pub duration: f64, + /// Whether the part can be decoded independently (starts a keyframe). pub independent: bool, } /// Lightweight per-segment metadata for rendering a playlist (no bytes). pub struct SegmentMeta { + /// HLS media sequence number of this segment. pub sequence: u64, + /// Parts making up this segment, in order. pub parts: Vec, + /// Total presentation duration so far (sum of part durations), in seconds. pub duration: f64, + /// Whether the segment is finalized (a later segment has opened). pub complete: bool, /// True if this segment opens a new continuity region (post pause/resume); the /// renderer emits `#EXT-X-DISCONTINUITY` before it. @@ -55,11 +63,17 @@ pub struct SegmentMeta { /// A point-in-time view of the store, used to render a media playlist without /// holding the lock during formatting. pub struct Snapshot { + /// Whether the init segment (`init.mp4`) is available. pub init_ready: bool, + /// LL-HLS PART-TARGET, in seconds. pub part_target: f64, + /// `EXT-X-MEDIA-SEQUENCE`: sequence of the first segment in the window. pub media_sequence: u64, + /// Sequence the next segment to roll will be assigned. pub next_sequence: u64, + /// Segments currently in the sliding window, oldest first. pub segments: Vec, + /// Whether the track has ended (the playlist gains `#EXT-X-ENDLIST`). pub finished: bool, } @@ -67,9 +81,13 @@ pub struct Snapshot { /// `(_HLS_msn, _HLS_part)` target has been reached, without locking. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Version { + /// Sequence of the newest segment. pub last_sequence: u64, + /// Number of parts in the newest segment. pub last_parts: usize, + /// Sequence of the oldest segment still in the window. pub media_sequence: u64, + /// Whether the track has ended. pub finished: bool, } @@ -87,7 +105,8 @@ struct Inner { pub struct SegmentStore { inner: Mutex, notify: watch::Sender, - is_video: bool, + /// Video rolls a segment per GOP; audio rolls on duration. + kind: Kind, /// LL-HLS PART-TARGET, in seconds. part_target: f64, /// Target segment duration for audio (video rolls on GOP boundaries instead). @@ -98,7 +117,9 @@ pub struct SegmentStore { } impl SegmentStore { - pub fn new(is_video: bool, part_target: f64, audio_segment_target: f64, window: f64) -> Self { + /// Create an empty store for a rendition of `kind`, taking part/segment/window + /// timing from `config`. + pub fn new(kind: Kind, config: &Config) -> Self { let (notify, _) = watch::channel(Version::default()); Self { inner: Mutex::new(Inner { @@ -109,10 +130,10 @@ impl SegmentStore { discontinuity_pending: false, }), notify, - is_video, - part_target, - audio_segment_target, - window, + kind, + part_target: config.part_target.as_secs_f64(), + audio_segment_target: config.audio_segment_target.as_secs_f64(), + window: config.window.as_secs_f64(), } } @@ -136,7 +157,7 @@ impl SegmentStore { || match inner.segments.back() { None => true, Some(cur) => { - if self.is_video { + if self.kind == Kind::Video { // A new GOP (independent fragment) starts a new segment. fragment.independent } else { @@ -212,6 +233,7 @@ impl SegmentStore { let _ = self.notify.send(version); } + /// Current [`Version`] watermark (newest sequence/part counts and window edge). pub fn version(&self) -> Version { let inner = self.inner.lock().unwrap(); let media_sequence = inner @@ -235,10 +257,12 @@ impl SegmentStore { } } + /// Subscribe to [`Version`] updates (one tick per new part/segment/finish). pub fn subscribe(&self) -> watch::Receiver { self.notify.subscribe() } + /// The init segment (`init.mp4`) bytes, once available. pub fn init(&self) -> Option { self.inner.lock().unwrap().init.clone() } diff --git a/rs/moq-hls/src/import.rs b/rs/moq-hls/src/import.rs index 70f006986d..c92146730d 100644 --- a/rs/moq-hls/src/import.rs +++ b/rs/moq-hls/src/import.rs @@ -16,12 +16,20 @@ use m3u8_rs::{ }; use moq_mux::catalog::Producer as CatalogProducer; use moq_mux::container::fmp4::Import as Fmp4; +use moq_mux::select; use reqwest::Client; use tracing::{debug, info, warn}; use url::Url; use crate::{Error, Result}; +/// Per-request timeout for the default HTTP client (playlist + segment fetches). +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Backoff before retrying after a failed import step, so a transient upstream +/// error (a 5xx, a truncated segment) doesn't tear down the whole import. +const ERROR_BACKOFF: Duration = Duration::from_secs(1); + /// Configuration for the single-rendition HLS import loop. #[derive(Clone)] pub struct Config { @@ -34,6 +42,7 @@ pub struct Config { } impl Config { + /// Create an import configuration for `playlist` using the default HTTP client. pub fn new(playlist: String) -> Self { Self { playlist, client: None } } @@ -99,30 +108,55 @@ enum TrackKind { struct TrackState { playlist: Url, + // Which roles this playlist's importer publishes (a muxed variant alongside a + // separate audio rendition publishes video only). + select: select::Broadcast, next_sequence: Option, + next_discontinuity: Option, init_ready: bool, } impl TrackState { - fn new(playlist: Url) -> Self { + fn new(playlist: Url, select: select::Broadcast) -> Self { Self { playlist, + select, next_sequence: None, + next_discontinuity: None, init_ready: false, } } } +/// Selection for a muxed rendition (the only source): publish every track. +fn select_muxed() -> select::Broadcast { + select::Broadcast::default() + .video(select::Video::default()) + .audio(select::Audio::default()) +} + +/// Selection for a video variant that has a separate audio rendition: video only. +fn select_video_only() -> select::Broadcast { + select::Broadcast::default().video(select::Video::default()) +} + +/// Selection for a separate audio rendition: audio only. +fn select_audio_only() -> select::Broadcast { + select::Broadcast::default().audio(select::Audio::default()) +} + impl Import { /// Create a new HLS import that will write into the given broadcast. pub fn new(broadcast: moq_net::BroadcastProducer, catalog: CatalogProducer, cfg: Config) -> Result { let base_url = cfg.parse_playlist()?; - let client = cfg.client.unwrap_or_else(|| { - Client::builder() + let client = match cfg.client { + Some(client) => client, + None => Client::builder() .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) - .build() - .unwrap() - }); + // Bound playlist/segment fetches so a stuck request can't wedge `run()`. + .timeout(REQUEST_TIMEOUT) + .build()?, + }; Ok(Self { broadcast, catalog, @@ -149,9 +183,19 @@ impl Import { } /// Run the import loop until cancelled. + /// + /// A failed step (e.g. a transient playlist fetch error) is logged and + /// retried after a short backoff rather than ending the import. pub async fn run(&mut self) -> Result<()> { loop { - let outcome = self.step().await?; + let outcome = match self.step().await { + Ok(outcome) => outcome, + Err(err) => { + warn!(%err, "HLS import step failed, retrying"); + tokio::time::sleep(ERROR_BACKOFF).await; + continue; + } + }; let delay = self.refresh_delay(outcome.target_duration, outcome.wrote_segments); info!( @@ -210,31 +254,27 @@ impl Import { let mut wrote = 0usize; let mut target_duration = None; - // Ingest a step from all active video variants. + // Ingest a step from all active video variants. A single variant failing is + // logged and skipped (the track is always restored) so one bad rendition or + // segment doesn't drop the others or abort the whole step. let video_tracks = std::mem::take(&mut self.video); for (index, mut track) in video_tracks.into_iter().enumerate() { - let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; - // Use the first video's target duration as the base. - if target_duration.is_none() { - target_duration = Some(playlist.target_duration); + match self + .ingest(TrackKind::Video(index), &mut track, &mut target_duration) + .await + { + Ok(count) => wrote += count, + Err(err) => warn!(index, %err, "video rendition import step failed, will retry"), } - let count = self - .consume_segments(TrackKind::Video(index), &mut track, &playlist, None) - .await?; - wrote += count; self.video.push(track); } // Ingest from the shared audio track, if present. if let Some(mut track) = self.audio.take() { - let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; - if target_duration.is_none() { - target_duration = Some(playlist.target_duration); + match self.ingest(TrackKind::Audio, &mut track, &mut target_duration).await { + Ok(count) => wrote += count, + Err(err) => warn!(%err, "audio rendition import step failed, will retry"), } - let count = self - .consume_segments(TrackKind::Audio, &mut track, &playlist, None) - .await?; - wrote += count; self.audio = Some(track); } @@ -244,6 +284,21 @@ impl Import { }) } + /// Fetch one track's current media playlist and consume any fresh segments, + /// recording the playlist's target duration if not already known. + async fn ingest( + &mut self, + kind: TrackKind, + track: &mut TrackState, + target_duration: &mut Option, + ) -> Result { + let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; + if target_duration.is_none() { + *target_duration = Some(playlist.target_duration); + } + self.consume_segments(kind, track, &playlist, None).await + } + /// Compute the delay before the next import step should run. fn refresh_delay(&self, target_duration: Option, wrote_segments: usize) -> Duration { let base = target_duration @@ -278,18 +333,13 @@ impl Import { return Err(Error::NoVariants); } - // Create a video track state for every usable variant. - for variant in &variants { - let video_url = resolve_uri(&self.base_url, &variant.uri)?; - self.video.push(TrackState::new(video_url)); - } - - // Choose an audio rendition based on the first variant with an audio group. + // Choose an audio rendition first, so the video variants below know whether + // they need to drop their muxed audio. if let Some(group_id) = variants.iter().find_map(|v| v.audio.as_deref()) { if let Some(audio_tag) = select_audio(&master, group_id) { if let Some(uri) = &audio_tag.uri { let audio_url = resolve_uri(&self.base_url, uri)?; - self.audio = Some(TrackState::new(audio_url)); + self.audio = Some(TrackState::new(audio_url, select_audio_only())); } else { warn!(%group_id, "audio rendition missing URI"); } @@ -298,6 +348,18 @@ impl Import { } } + // With a separate audio rendition, the variants are muxed but should publish + // video only so the audio isn't duplicated; otherwise import every track. + let variant_select = if self.audio.is_some() { + select_video_only() + } else { + select_muxed() + }; + for variant in &variants { + let video_url = resolve_uri(&self.base_url, &variant.uri)?; + self.video.push(TrackState::new(video_url, variant_select.clone())); + } + let audio_url = self.audio.as_ref().map(|a| a.playlist.to_string()); info!( video_variants = variants.len(), @@ -308,8 +370,8 @@ impl Import { return Ok(()); } - // Fallback: treat the provided URL as a single media playlist. - self.video.push(TrackState::new(self.base_url.clone())); + // Fallback: treat the provided URL as a single (muxed) media playlist. + self.video.push(TrackState::new(self.base_url.clone(), select_muxed())); Ok(()) } @@ -327,22 +389,30 @@ impl Import { let total_segments = playlist.segments.len(); let last_playlist_seq = playlist_seq + total_segments as u64; + // Both out-of-window cases re-anchor to the current playlist (skip 0) and clear + // `next_sequence` so the next push re-bases. The warning is suppressed on the + // first step (`next_sequence` still None), where starting mid-window is normal. let skip = if next_seq > last_playlist_seq { - warn!( - ?kind, - next_sequence = next_seq, - playlist_sequence = playlist_seq, - last_playlist_sequence = last_playlist_seq, - "imported ahead of playlist, waiting for new segments" - ); - total_segments + if track.next_sequence.is_some() { + warn!( + ?kind, + next_sequence = next_seq, + playlist_sequence = playlist_seq, + last_playlist_sequence = last_playlist_seq, + "imported ahead of playlist (upstream sequence reset?), re-anchoring to current window" + ); + } + track.next_sequence = None; + 0 } else if next_seq < playlist_seq { - warn!( - ?kind, - next_sequence = next_seq, - playlist_sequence = playlist_seq, - "next_sequence behind playlist, resetting to start of playlist" - ); + if track.next_sequence.is_some() { + warn!( + ?kind, + next_sequence = next_seq, + playlist_sequence = playlist_seq, + "next_sequence behind playlist, resetting to start of playlist" + ); + } track.next_sequence = None; 0 } else { @@ -350,9 +420,10 @@ impl Import { }; let available = total_segments.saturating_sub(skip); - let to_process = match limit { - Some(max) => available.min(max), - None => available, + let to_process = if let Some(max) = limit { + available.min(max) + } else { + available }; info!( @@ -367,8 +438,23 @@ impl Import { if to_process > 0 { let base_seq = playlist_seq + skip as u64; + + // Discontinuity sequence names a fresh media timeline; the tag count for the + // skipped prefix gets us to the discontinuity sequence of the first segment + // we actually push. + let mut discontinuity_seq = playlist.discontinuity_sequence; + for segment in &playlist.segments[..skip] { + if segment.discontinuity { + discontinuity_seq = bump_discontinuity(discontinuity_seq)?; + } + } + for (i, segment) in playlist.segments[skip..skip + to_process].iter().enumerate() { - self.push_segment(kind, track, segment, base_seq + i as u64).await?; + if segment.discontinuity { + discontinuity_seq = bump_discontinuity(discontinuity_seq)?; + } + self.push_segment(kind, track, segment, base_seq + i as u64, discontinuity_seq) + .await?; } info!(?kind, consumed = to_process, "consumed HLS segments"); } else { @@ -393,8 +479,8 @@ impl Import { let url = resolve_uri(&track.playlist, &map.uri)?; let bytes = self.fetch_bytes(url).await?; let importer = match kind { - TrackKind::Video(index) => self.ensure_video_importer_for(index), - TrackKind::Audio => self.ensure_audio_importer(), + TrackKind::Video(index) => self.ensure_video_importer_for(index, &track.select), + TrackKind::Audio => self.ensure_audio_importer(&track.select), }; // The importer buffers internally, so a fully-parsed init segment leaves it @@ -413,6 +499,7 @@ impl Import { track: &mut TrackState, segment: &MediaSegment, sequence: u64, + discontinuity_sequence: u64, ) -> Result<()> { if segment.uri.is_empty() { return Err(Error::EmptySegmentUri); @@ -421,22 +508,27 @@ impl Import { let url = resolve_uri(&track.playlist, &segment.uri)?; let bytes = self.fetch_bytes(url).await?; - // Ensure the importer is initialized before processing fragments - // Use track.init_ready to avoid borrowing issues - if !track.init_ready { - // Try to ensure init segment is processed - let playlist = self.fetch_media_playlist(track.playlist.clone()).await?; - self.ensure_init_segment(kind, track, &playlist).await?; - } - - // Get importer after ensuring init segment + // `consume_segments` always runs `ensure_init_segment` before reaching here, so + // the importer is already initialized. let importer = match kind { - TrackKind::Video(index) => self.ensure_video_importer_for(index), - TrackKind::Audio => self.ensure_audio_importer(), + TrackKind::Video(index) => self.ensure_video_importer_for(index, &track.select), + TrackKind::Audio => self.ensure_audio_importer(&track.select), }; + // HLS media sequence names the live window, while discontinuity sequence names a + // new media timeline. Whenever we join, skip ahead, or cross a discontinuity, anchor + // the MoQ group sequence to both so consumers do not wait on groups HLS has moved + // past. Contiguous segments let the importer auto-increment instead; we still pack + // (and so validate) the sequence on that path so media sequence can't silently + // auto-increment into the discontinuity bits. + let group_sequence = moq_sequence(discontinuity_sequence, sequence)?; + if track.next_sequence != Some(sequence) || track.next_discontinuity != Some(discontinuity_sequence) { + importer.seek(group_sequence)?; + } + importer.decode(&bytes)?; track.next_sequence = Some(sequence + 1); + track.next_discontinuity = Some(discontinuity_sequence); Ok(()) } @@ -462,9 +554,9 @@ impl Import { /// /// Each video variant gets its own importer so that their tracks remain /// independent while still contributing to the same shared catalog. - fn ensure_video_importer_for(&mut self, index: usize) -> &mut Fmp4 { + fn ensure_video_importer_for(&mut self, index: usize, select: &select::Broadcast) -> &mut Fmp4 { while self.video_importers.len() <= index { - let importer = Fmp4::new(self.broadcast.clone(), self.catalog.clone()); + let importer = Fmp4::new(self.broadcast.clone(), self.catalog.clone()).with_select(select.clone()); self.video_importers.push(importer); } @@ -472,9 +564,12 @@ impl Import { } /// Create or retrieve the fMP4 importer for the audio rendition. - fn ensure_audio_importer(&mut self) -> &mut Fmp4 { + fn ensure_audio_importer(&mut self, select: &select::Broadcast) -> &mut Fmp4 { + let broadcast = self.broadcast.clone(); + let catalog = self.catalog.clone(); + let select = select.clone(); self.audio_importer - .get_or_insert_with(|| Fmp4::new(self.broadcast.clone(), self.catalog.clone())) + .get_or_insert_with(|| Fmp4::new(broadcast, catalog).with_select(select)) } #[cfg(test)] @@ -510,12 +605,6 @@ fn select_audio<'a>(master: &'a MasterPlaylist, group_id: &str) -> Option<&'a Al } fn select_variants(master: &MasterPlaylist) -> Vec<&VariantStream> { - // Helper to extract the first video codec token from the CODECS attribute. - fn first_video_codec(variant: &VariantStream) -> Option<&str> { - let codecs = variant.codecs.as_deref()?; - codecs.split(',').map(|s| s.trim()).find(|s| !s.is_empty()) - } - // Map codec strings into a coarse "family" so we can prefer H.264 over others. fn codec_family(codec: &str) -> Option<&'static str> { if codec.starts_with("avc1.") || codec.starts_with("avc3.") { @@ -525,6 +614,16 @@ fn select_variants(master: &MasterPlaylist) -> Vec<&VariantStream> { } } + // Extract the first *video* codec token from the CODECS attribute. A list like + // `mp4a.40.2,avc1.4d401f` (audio first) must still surface the video codec. + fn first_video_codec(variant: &VariantStream) -> Option<&str> { + let codecs = variant.codecs.as_deref()?; + codecs + .split(',') + .map(|s| s.trim()) + .find(|codec| codec_family(codec).is_some()) + } + // Consider only non-i-frame variants with a URI and a known codec family. let candidates: Vec<(&VariantStream, &str, &str)> = master .variants @@ -591,6 +690,41 @@ fn resolve_uri(base: &Url, value: &str) -> std::result::Result Result { + sequence.checked_add(1).ok_or(Error::SequenceOverflow { + kind: "discontinuity", + value: sequence, + }) +} + +/// Pack HLS discontinuity + media sequence into a single MoQ group sequence. +/// +/// HLS media sequence alone can rewind after an upstream reset, and discontinuity +/// sequence alone cannot order segments inside the same epoch. The lower 48 bits hold +/// the media sequence (ample for realistic playlists) while the upper 16 bits hold the +/// discontinuity sequence, so a new epoch always sorts after every segment of the last. +fn moq_sequence(discontinuity_sequence: u64, media_sequence: u64) -> Result { + const MEDIA_BITS: u32 = 48; + const MEDIA_MASK: u64 = (1u64 << MEDIA_BITS) - 1; + const DISCONTINUITY_MASK: u64 = u64::MAX >> MEDIA_BITS; + + if media_sequence > MEDIA_MASK { + return Err(Error::SequenceOverflow { + kind: "media", + value: media_sequence, + }); + } + if discontinuity_sequence > DISCONTINUITY_MASK { + return Err(Error::SequenceOverflow { + kind: "discontinuity", + value: discontinuity_sequence, + }); + } + + Ok((discontinuity_sequence << MEDIA_BITS) | media_sequence) +} + #[cfg(test)] mod tests { use super::*; @@ -602,6 +736,15 @@ mod tests { assert_eq!(cfg.playlist, url); } + #[test] + fn select_variants_handles_audio_first_codecs() { + // CODECS lists the audio codec first; the video codec must still be found. + let master = b"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"mp4a.40.2,avc1.4d401f\"\nvideo.m3u8\n"; + let (_, master) = m3u8_rs::parse_master_playlist(master).unwrap(); + let variants = select_variants(&master); + assert_eq!(variants.len(), 1); + } + #[test] fn hls_import_starts_without_importers() { let mut broadcast = moq_net::BroadcastInfo::new().produce(); @@ -613,4 +756,86 @@ mod tests { assert!(!hls.has_video_importer()); assert!(!hls.has_audio_importer()); } + + /// Resolve `ensure_tracks` against a master playlist written to a temp file. + async fn discover(master_body: &str) -> Import { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("moq-hls-test-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("master.m3u8"); + std::fs::write(&path, master_body).unwrap(); + + let mut broadcast = moq_net::BroadcastInfo::new().produce(); + let catalog = CatalogProducer::new(&mut broadcast).unwrap(); + // `Config` takes a filesystem path for non-http inputs. + let cfg = Config::new(path.to_str().unwrap().to_string()); + let mut hls = Import::new(broadcast, catalog, cfg).unwrap(); + hls.ensure_tracks().await.unwrap(); + hls + } + + /// A master with a separate audio rendition: variants publish video only, the + /// alternate rendition publishes audio only. + #[tokio::test] + async fn discover_splits_separate_audio() { + let master = "#EXTM3U\n\ + #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",URI=\"audio.m3u8\"\n\ + #EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.4d401f,mp4a.40.2\",AUDIO=\"aud\"\n\ + video.m3u8\n"; + let hls = discover(master).await; + + assert_eq!(hls.video.len(), 1); + assert!(hls.video[0].select.has_video() && !hls.video[0].select.has_audio()); + + let audio = hls.audio.as_ref().expect("separate audio rendition"); + assert!(audio.select.has_audio() && !audio.select.has_video()); + } + + /// A master whose variant carries muxed A/V (no separate audio group) publishes + /// every track. + #[tokio::test] + async fn discover_muxed_variant_keeps_both() { + let master = "#EXTM3U\n\ + #EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.4d401f\"\n\ + video.m3u8\n"; + let hls = discover(master).await; + + assert_eq!(hls.video.len(), 1); + assert!(hls.video[0].select.has_video() && hls.video[0].select.has_audio()); + assert!(hls.audio.is_none()); + } + + #[test] + fn moq_sequence_orders_discontinuities_after_media_sequence() { + // A new epoch outranks every segment of the previous one, even a higher media seq. + let last_of_epoch_0 = moq_sequence(0, u64::from(u32::MAX)).unwrap(); + let first_of_epoch_1 = moq_sequence(1, 0).unwrap(); + assert!(first_of_epoch_1 > last_of_epoch_0); + } + + #[test] + fn moq_sequence_preserves_media_order_within_epoch() { + assert!(moq_sequence(3, 10).unwrap() > moq_sequence(3, 9).unwrap()); + } + + #[test] + fn moq_sequence_rejects_unrepresentable_media_sequence() { + let err = moq_sequence(0, 1u64 << 48).unwrap_err(); + assert!(matches!(err, Error::SequenceOverflow { kind: "media", .. })); + } + + #[test] + fn moq_sequence_rejects_unrepresentable_discontinuity_sequence() { + let err = moq_sequence(1u64 << 16, 0).unwrap_err(); + assert!(matches!( + err, + Error::SequenceOverflow { + kind: "discontinuity", + .. + } + )); + } } diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index 5a40421a8d..43d08500df 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -55,7 +55,15 @@ async fn media( block_until(&store, msn, part).await; } - m3u8(crate::export::render_media(&store.snapshot())) + let snapshot = store.snapshot(); + + // Don't advertise a rendition the player can't bootstrap yet: the playlist + // references init.mp4, which 404s until the first (init) fragment lands. + if !snapshot.init_ready { + return not_found(); + } + + m3u8(crate::export::render_media(&snapshot)) } async fn init(State(server): State, Path((broadcast, rendition)): Path<(String, String)>) -> Response { @@ -95,6 +103,14 @@ async fn part( return not_found(); }; + // A legit preload-hint part is at most one sequence past the current last segment. + // Reject anything further ahead immediately rather than holding the connection for + // the full block timeout on a bogus/scanning request. + let version = store.version(); + if !version.finished && sequence > version.last_sequence + 1 { + return not_found(); + } + // The part may be a preload hint that hasn't been produced yet; block briefly. block_until(&store, sequence, index).await; diff --git a/rs/moq-json/CHANGELOG.md b/rs/moq-json/CHANGELOG.md index 32aa626a8a..77a33d870c 100644 --- a/rs/moq-json/CHANGELOG.md +++ b/rs/moq-json/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1](https://github.com/moq-dev/moq/compare/moq-json-v0.1.0...moq-json-v0.1.1) - 2026-06-30 + +### Other + +- *(moq-json)* generate merge patches with a diffing serializer (+ benchmark) ([#1912](https://github.com/moq-dev/moq/pull/1912)) +- *(moq-json)* gate group rolls on already-written deltas ([#1909](https://github.com/moq-dev/moq/pull/1909)) + ## [0.0.4](https://github.com/moq-dev/moq/compare/moq-json-v0.0.3...moq-json-v0.0.4) - 2026-06-17 ### Added diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index aaf984049f..bbc588c42f 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/examples/telemetry.rs b/rs/moq-json/examples/telemetry.rs new file mode 100644 index 0000000000..c6fde78ca1 --- /dev/null +++ b/rs/moq-json/examples/telemetry.rs @@ -0,0 +1,219 @@ +//! Measure wire savings of group-scoped DEFLATE + snapshot/delta on a telemetry stream. +//! +//! Simulates a realistic device telemetry blob that ticks once per second: most of the document +//! is static (identity, config, geo) while a handful of gauges and counters change each tick. This +//! is exactly the shape `moq-json` targets, so it shows the snapshot/delta and compression knobs +//! pulling in the same direction. +//! +//! Run with: `cargo run -p moq-json --example telemetry` + +use std::task::Poll; + +use moq_json::{ConsumerConfig, Producer, ProducerConfig}; +use serde_json::{Value, json}; + +/// One second of telemetry for a fleet device: a big static core plus a few moving numbers. +fn telemetry(tick: u64) -> Value { + // A slow drift so consecutive ticks differ by a little, like real sensors. + let t = tick as f64; + let lat = 37.7749 + (t * 0.0001).sin() * 0.01; + let lon = -122.4194 + (t * 0.0001).cos() * 0.01; + + json!({ + "device": { + "id": "veh-4417-a2", + "model": "Sentinel X2", + "firmware": "4.18.2-rc1", + "serial": "SNX2-0000-4417-A2C9", + "region": "us-west-2", + "fleet": "logistics-prod", + "tags": ["cold-chain", "long-haul", "priority"], + }, + "config": { + "sample_hz": 1, + "upload_hz": 1, + "geofence": "bay-area", + "thresholds": { "temp_c": 8.0, "humidity": 85, "shock_g": 3.5, "battery_pct": 15 }, + "contacts": ["ops@example.com", "fleet@example.com"], + }, + "ts": 1_700_000_000 + tick, + "uptime_s": tick, + "location": { + "lat": (lat * 1e6).round() / 1e6, + "lon": (lon * 1e6).round() / 1e6, + "alt_m": 12 + (tick % 5), + "heading": (tick * 7) % 360, + "speed_kph": 40 + (tick % 25), + "fix": "3d", + "sats": 9 + (tick % 3), + }, + "sensors": { + "temp_c": (4.0 + (t * 0.05).sin() * 1.5 * 100.0).round() / 100.0, + "humidity": 60 + (tick % 10), + "shock_g": (((t * 0.3).sin().abs()) * 100.0).round() / 100.0, + "door_open": tick % 30 == 0, + }, + "power": { + "battery_pct": 100 - (tick / 6) % 100, + "charging": false, + "voltage_mv": 12_400 - (tick % 50) as i64, + "current_ma": 850 + (tick % 120) as i64, + }, + "network": { + "rssi_dbm": -70 - (tick % 15) as i64, + "type": "lte", + "bytes_up": 1_024 * tick, + "bytes_down": 256 * tick, + "latency_ms": 35 + (tick % 40), + }, + "counters": { + "events": tick, + "errors": tick / 50, + "reconnects": tick / 120, + }, + }) +} + +/// Total wire bytes of every frame across every group for a full run under `config`. +fn wire_bytes(config: ProducerConfig, ticks: u64) -> usize { + let track = moq_net::BroadcastInfo::new() + .produce() + .create_track("telemetry", None) + .unwrap(); + let consumer = track.subscribe(None); + let mut producer = Producer::::new(track, config); + + for tick in 0..ticks { + producer.update(&telemetry(tick)).unwrap(); + } + producer.finish().unwrap(); + + // Drain the raw stored frames (compressed if the producer compressed them) and sum their sizes. + let waiter = kio::Waiter::noop(); + let mut total = 0; + let mut track = consumer; + while let Poll::Ready(Ok(Some(mut group))) = track.poll_next_group(&waiter) { + while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) { + total += frame.len(); + } + } + total +} + +/// Drive a producer and a live consumer in lockstep, asserting that EVERY tick reconstructs to the +/// exact input value after decompression and delta application (not just the final one). +fn verify(producer_config: ProducerConfig, ticks: u64) { + let track = moq_net::BroadcastInfo::new() + .produce() + .create_track("telemetry", None) + .unwrap(); + let consumer = track.subscribe(None); + let mut producer = Producer::::new(track, producer_config.clone()); + + let mut consumer_config = ConsumerConfig::default(); + consumer_config.compression = producer_config.compression; + let mut consumer = moq_json::Consumer::::new(consumer, consumer_config); + let waiter = kio::Waiter::noop(); + + for tick in 0..ticks { + let expected = telemetry(tick); + producer.update(&expected).unwrap(); + // The producer emits exactly one frame per update, so the live consumer yields exactly one + // reconstructed value: it must match the input byte-for-byte after decompression + patching. + match consumer.poll_next(&waiter) { + Poll::Ready(Ok(Some(value))) => assert_eq!(value, expected, "tick {tick} reconstruction mismatch"), + other => panic!("tick {tick}: expected a value, got {other:?}"), + } + } + producer.finish().unwrap(); + + // Drain: nothing left and the stream ends cleanly. + assert!( + matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(None))), + "stream did not end cleanly" + ); +} + +/// A consumer that joins only after the whole stream exists must still rebuild the latest value from +/// the newest group's snapshot + deltas. For the compressed path this exercises the lazy decoder +/// replaying the group's already-stored slices to warm its window before decoding the final frame. +/// +/// Returns how many values the late joiner surfaced to the application: with backlog collapsing this +/// is far below `ticks`, since stale intermediate reconstructions are applied internally but skipped. +fn verify_late_joiner(producer_config: ProducerConfig, ticks: u64) -> usize { + let track = moq_net::BroadcastInfo::new() + .produce() + .create_track("telemetry", None) + .unwrap(); + let consumer = track.subscribe(None); + let mut producer = Producer::::new(track, producer_config.clone()); + for tick in 0..ticks { + producer.update(&telemetry(tick)).unwrap(); + } + producer.finish().unwrap(); + + let mut consumer_config = ConsumerConfig::default(); + consumer_config.compression = producer_config.compression; + let mut consumer = moq_json::Consumer::::new(consumer, consumer_config); + let waiter = kio::Waiter::noop(); + let mut last = None; + let mut yielded = 0; + while let Poll::Ready(Ok(Some(value))) = consumer.poll_next(&waiter) { + last = Some(value); + yielded += 1; + } + assert_eq!( + last.as_ref(), + Some(&telemetry(ticks - 1)), + "late joiner reconstruction mismatch" + ); + yielded +} + +fn cfg(delta_ratio: u32, compression: bool) -> ProducerConfig { + let mut config = ProducerConfig::default(); + config.delta_ratio = delta_ratio; + config.compression = compression; + config +} + +fn main() { + const TICKS: u64 = 60; + + // Raw baseline: every tick as a full JSON blob, no moq-json framing tricks. + let raw: usize = (0..TICKS) + .map(|t| serde_json::to_vec(&telemetry(t)).unwrap().len()) + .sum(); + let snapshot_len = serde_json::to_vec(&telemetry(0)).unwrap().len(); + + let combos = [ + ("snapshot-per-group, plaintext", cfg(0, false)), + ("snapshot-per-group, deflate ", cfg(0, true)), + ("snapshot+delta, plaintext", cfg(8, false)), + ("snapshot+delta, deflate ", cfg(8, true)), + ]; + + println!("Telemetry stream: {TICKS} ticks, ~{snapshot_len} bytes per snapshot\n"); + println!("Raw JSON (one blob per tick): {raw:>8} bytes (baseline)\n"); + + println!("{:<32} {:>10} {:>10} {:>9}", "config", "wire", "vs raw", "saved"); + println!("{}", "-".repeat(64)); + for (name, config) in combos.clone() { + verify(config.clone(), TICKS); + verify_late_joiner(config.clone(), TICKS); + let bytes = wire_bytes(config, TICKS); + let pct = 100.0 * bytes as f64 / raw as f64; + let saved = 100.0 - pct; + println!("{name:<32} {bytes:>8} B {pct:>8.1}% {saved:>7.1}%"); + } + + println!("\nVerified: every tick reconstructs exactly (live + late joiner) for all 4 configs."); + + // Late-joiner collapse: a consumer joining after all {TICKS} ticks exist gets the head in one + // step, not a replay of every superseded state. + println!("\nLate joiner: values surfaced to the app (was {TICKS} per-frame, now collapsed):"); + for (name, config) in combos { + let yielded = verify_late_joiner(config, TICKS); + println!(" {name:<32} {yielded:>3} value(s)"); + } +} diff --git a/rs/moq-msf/CHANGELOG.md b/rs/moq-msf/CHANGELOG.md index 935098c056..4e3aa7f83c 100644 --- a/rs/moq-msf/CHANGELOG.md +++ b/rs/moq-msf/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0](https://github.com/moq-dev/moq/compare/moq-msf-v0.2.0...moq-msf-v0.3.0) - 2026-06-30 + +### Other + +- Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) ([#1918](https://github.com/moq-dev/moq/pull/1918)) + ### Changed - Track draft-ietf-moq-msf-01, with the wire format hidden behind the API. `Catalog` is now a version-agnostic snapshot (`{ tracks }`); the `version` field and the `Version` enum are gone. Parsing accepts draft-00 (numeric `version`, inline `initData`) and draft-01 (string `version`, root `initDataList` + per-track `initRef`); serializing always emits draft-01. Init data is resolved to inline `Track::init_data` on parse and hoisted into a deduplicated `initDataList` on serialize, so callers never touch the version or the init-data indirection. diff --git a/rs/moq-msf/Cargo.toml b/rs/moq-msf/Cargo.toml index 65618fd9c4..33603ed29f 100644 --- a/rs/moq-msf/Cargo.toml +++ b/rs/moq-msf/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.0" +version = "0.3.0" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index bcd921b29f..6c958cc7d1 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0](https://github.com/moq-dev/moq/compare/moq-mux-v0.6.0...moq-mux-v0.7.0) - 2026-06-30 + +### Added + +- *(moq-rtc)* add WebRTC (WHIP/WHEP) gateway ([#1916](https://github.com/moq-dev/moq/pull/1916)) +- *(moq-srt)* bidirectional SRT/MPEG-TS gateway (+ timestamped ts::Export) ([#1915](https://github.com/moq-dev/moq/pull/1915)) +- *(moq-rtmp)* RTMP/E-RTMP gateway + enhanced-RTMP FLV codecs on main ([#1914](https://github.com/moq-dev/moq/pull/1914)) +- *(hang)* compressed catalog track (catalog.json.z) ([#1904](https://github.com/moq-dev/moq/pull/1904)) +- *(json)* group-scoped DEFLATE compression with browser support ([#1897](https://github.com/moq-dev/moq/pull/1897)) +- *(moq-mux)* clear consumer buffer when group timestamps rewind ([#1884](https://github.com/moq-dev/moq/pull/1884)) + +### Fixed + +- *(moq-mux)* codec/container correctness fixes from #1918 review ([#1923](https://github.com/moq-dev/moq/pull/1923)) ([#1925](https://github.com/moq-dev/moq/pull/1925)) + +### Other + +- drop redundant non_exhaustive on select builders ([#1944](https://github.com/moq-dev/moq/pull/1944)) +- unify rendition selection behind select::Broadcast +- API cleanup before the semver bump ([#1941](https://github.com/moq-dev/moq/pull/1941)) +- [codex] Route HLS CLI import through moq-hls ([#1939](https://github.com/moq-dev/moq/pull/1939)) +- Fix fMP4 zero-duration samples ([#1933](https://github.com/moq-dev/moq/pull/1933)) +- [codex] Backport moq-hls to main ([#1924](https://github.com/moq-dev/moq/pull/1924)) +- Backport moq-mux to main (adapted to main's moq-net, no wire/API breaks) ([#1918](https://github.com/moq-dev/moq/pull/1918)) + ### Changed - Emit MSF catalogs at draft-ietf-moq-msf-01: `version` is the string `"draft-01"` and init data is carried via the root `initDataList` + per-track `initRef`. The MSF consumer still accepts draft-00 (numeric `version`, inline `initData`). diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 506db76f32..243ec4d803 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.6.0" +version = "0.7.0" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/src/catalog/consumer.rs b/rs/moq-mux/src/catalog/consumer.rs index a8bcd7c2f4..7aedcadee8 100644 --- a/rs/moq-mux/src/catalog/consumer.rs +++ b/rs/moq-mux/src/catalog/consumer.rs @@ -13,10 +13,16 @@ use super::{CatalogFormat, Stream}; /// /// Both variants emit [`Catalog`](super::hang::Catalog); the MSF variant is /// media-only, so its extension is always the default. Wrap with -/// [`Filter`](super::Filter) / [`Target`](super::Target) to narrow the -/// rendition set before handing the stream to an exporter. +/// [`Select`](super::Select) to narrow the rendition set before handing the +/// stream to an exporter. +/// +/// The variants are an implementation detail: drive it through the [`Stream`] +/// trait rather than matching on them. New catalog encodings may be added. +#[non_exhaustive] pub enum Consumer { + #[doc(hidden)] Hang(super::hang::Consumer), + #[doc(hidden)] Msf(super::msf::Consumer), } @@ -31,6 +37,13 @@ impl Consumer { .await?; Self::Hang(super::hang::Consumer::new(track)) } + CatalogFormat::HangZ => { + let track = broadcast + .track(hang::Catalog::COMPRESSED_NAME)? + .subscribe(hang::Catalog::default_subscription()) + .await?; + Self::Hang(super::hang::Consumer::compressed(track)) + } CatalogFormat::Msf => { let track = broadcast.track(moq_msf::DEFAULT_NAME)?.subscribe(None).await?; Self::Msf(super::msf::Consumer::new(track)) diff --git a/rs/moq-mux/src/catalog/filter.rs b/rs/moq-mux/src/catalog/filter.rs deleted file mode 100644 index d5ac6e956b..0000000000 --- a/rs/moq-mux/src/catalog/filter.rs +++ /dev/null @@ -1,362 +0,0 @@ -//! Hard-match rendition filter. -//! -//! [`Filter`] wraps any [`Stream`] and drops renditions that don't satisfy a -//! [`FilterVideo`] / [`FilterAudio`]. Matching is exact: a `name` constraint -//! keeps only the rendition with that key, a `codec` constraint keeps only -//! renditions whose codec family matches. Multiple constraints intersect. - -use std::task::Poll; - -use hang::catalog::{AudioCodecKind, VideoCodecKind}; - -use super::Stream; -use super::hang::{Catalog, CatalogExt}; - -/// Hard-match criteria for video renditions. -#[derive(Debug, Default, Clone)] -pub struct FilterVideo { - /// Keep only the rendition with this exact name. - pub name: Option, - /// Keep only renditions whose codec family matches. - pub codec: Option, -} - -/// Hard-match criteria for audio renditions. -#[derive(Debug, Default, Clone)] -pub struct FilterAudio { - /// Keep only the rendition with this exact name. - pub name: Option, - /// Keep only renditions whose codec family matches. - pub codec: Option, -} - -/// Shared state behind a [`Filter`]. -/// -/// `epoch` advances on every setter so [`Filter::poll_next`] can tell whether -/// the criteria changed since the last emit. -#[derive(Debug, Default, Clone)] -struct FilterState { - video: Option, - audio: Option, - epoch: u64, -} - -/// A [`Stream`] that drops renditions failing a [`FilterVideo`] / [`FilterAudio`]. -/// -/// Selection criteria live behind a [`kio::Producer`], so calls to -/// [`set_video`](Self::set_video) / [`set_audio`](Self::set_audio) wake any -/// pending `poll_next` instead of silently waiting for the next upstream -/// snapshot. -pub struct Filter { - inner: S, - state: kio::Producer, - state_consumer: kio::Consumer, - /// Last raw snapshot from `inner`, retained so a setter between snapshots - /// can re-apply without polling upstream. - last_input: Option>, - /// Epoch we already emitted against. - last_epoch: u64, - /// True once `inner` has handed us a snapshot we haven't emitted yet. - fresh_input: bool, -} - -impl Filter { - pub fn new(inner: S) -> Self { - let state = kio::Producer::new(FilterState::default()); - let state_consumer = state.consume(); - Self { - inner, - state, - state_consumer, - last_input: None, - last_epoch: 0, - fresh_input: false, - } - } - - /// Set or clear the video filter. Pass `None` to clear. - pub fn set_video(&mut self, filter: impl Into>) { - self.update(|s| s.video = filter.into()); - } - - /// Set or clear the audio filter. Pass `None` to clear. - pub fn set_audio(&mut self, filter: impl Into>) { - self.update(|s| s.audio = filter.into()); - } - - fn update(&self, f: impl FnOnce(&mut FilterState)) { - // `write()` only errors when the producer is closed, which can't happen - // while `self` holds the only producer handle. - let Ok(mut state) = self.state.write() else { - return; - }; - f(&mut state); - state.epoch = state.epoch.wrapping_add(1); - // Mut::drop wakes the paired consumer waiters here. - } -} - -impl Stream for Filter { - type Ext = S::Ext; - - fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { - let inner_eof = loop { - match self.inner.poll_next(waiter)? { - Poll::Ready(Some(snapshot)) => { - self.last_input = Some(snapshot); - self.fresh_input = true; - } - Poll::Ready(None) => break true, - Poll::Pending => break false, - } - }; - - let last_epoch = self.last_epoch; - let fresh_input = self.fresh_input; - let last_input = self.last_input.clone(); - - let polled = self.state_consumer.poll(waiter, |state| { - let filter_changed = state.epoch != last_epoch; - if !fresh_input && !filter_changed { - return Poll::Pending; - } - let Some(input) = last_input.clone() else { - return Poll::Pending; - }; - let emit = apply(input, state.video.as_ref(), state.audio.as_ref()); - Poll::Ready((emit, state.epoch)) - }); - - match polled { - Poll::Ready(Ok((emit, epoch))) => { - self.last_epoch = epoch; - self.fresh_input = false; - // End with upstream: if this is the final snapshot (inner already EOF'd), - // drop the retained input so a later filter change can't revive the stream - // after it has emitted its last value. - if inner_eof { - self.last_input = None; - } - Poll::Ready(Ok(Some(emit))) - } - Poll::Ready(Err(_)) => Poll::Ready(Ok(None)), - Poll::Pending => { - // EOF is terminal: once `inner` is exhausted and there's nothing fresh to - // emit, finish and drop the retained input so a post-EOF setter can't make - // the closure emit again (a still-pending snapshot returns Ready above). - if inner_eof { - self.last_input = None; - Poll::Ready(Ok(None)) - } else { - Poll::Pending - } - } - } - } -} - -/// Apply the active video / audio filters to a raw snapshot, dropping -/// renditions that don't match. Axes with no filter pass through unchanged. -fn apply( - mut catalog: Catalog, - video: Option<&FilterVideo>, - audio: Option<&FilterAudio>, -) -> Catalog { - if let Some(filter) = video { - catalog.video.renditions.retain(|name, config| { - if let Some(want) = &filter.name - && want != name - { - return false; - } - if let Some(want) = filter.codec - && config.codec.kind() != want - { - return false; - } - true - }); - } - if let Some(filter) = audio { - catalog.audio.renditions.retain(|name, config| { - if let Some(want) = &filter.name - && want != name - { - return false; - } - if let Some(want) = filter.codec - && config.codec.kind() != want - { - return false; - } - true - }); - } - catalog -} - -#[cfg(test)] -mod test { - use std::collections::BTreeMap; - - use hang::catalog::{AudioCodec, AudioConfig, Container, H264, VideoConfig}; - - use super::*; - - struct Once(Option); - - impl Stream for Once { - type Ext = (); - - fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { - Poll::Ready(Ok(self.0.take())) - } - } - - /// A still-live stream: yields its snapshot once, then parks (never EOFs). Models a - /// real upstream that stays open so post-snapshot retargeting is exercised without - /// tripping the end-with-upstream path. - struct Live(Option); - - impl Stream for Live { - type Ext = (); - - fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { - match self.0.take() { - Some(catalog) => Poll::Ready(Ok(Some(catalog))), - None => Poll::Pending, - } - } - } - - fn h264(name: &str) -> (String, VideoConfig) { - let mut config = VideoConfig::new(H264 { - profile: 0x42, - constraints: 0, - level: 0x1e, - inline: false, - }); - config.coded_width = Some(640); - config.coded_height = Some(360); - config.bitrate = Some(500_000); - config.framerate = Some(30.0); - config.container = Container::Legacy; - (name.to_string(), config) - } - - fn opus(name: &str) -> (String, AudioConfig) { - let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, 2); - config.bitrate = Some(128_000); - config.container = Container::Legacy; - (name.to_string(), config) - } - - fn catalog_with(video: Vec<(String, VideoConfig)>, audio: Vec<(String, AudioConfig)>) -> Catalog { - let mut c = Catalog::default(); - c.video.renditions = BTreeMap::from_iter(video); - c.audio.renditions = BTreeMap::from_iter(audio); - c - } - - #[test] - fn codec_filter_keeps_matching() { - let mut hd = h264("hd"); - hd.1.codec = hang::catalog::VP9 { - profile: 0, - level: 10, - bit_depth: 8, - chroma_subsampling: 1, - color_primaries: 1, - transfer_characteristics: 1, - matrix_coefficients: 1, - full_range: false, - } - .into(); - let snapshot = catalog_with(vec![h264("lo"), hd], vec![]); - - let mut f = Filter::new(Once(Some(snapshot))); - f.set_video(FilterVideo { - codec: Some(VideoCodecKind::H264), - ..Default::default() - }); - - let out = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("expected snapshot, got {other:?}"), - }; - assert_eq!(out.video.renditions.keys().collect::>(), vec!["lo"]); - } - - #[test] - fn name_filter_exact() { - let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); - let mut f = Filter::new(Once(Some(snapshot))); - f.set_video(FilterVideo { - name: Some("hi".into()), - ..Default::default() - }); - let out = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("got {other:?}"), - }; - assert_eq!(out.video.renditions.keys().collect::>(), vec!["hi"]); - } - - #[test] - fn audio_filter_independent_of_video() { - let snapshot = catalog_with(vec![h264("hi")], vec![opus("en"), opus("es")]); - let mut f = Filter::new(Once(Some(snapshot))); - f.set_audio(FilterAudio { - name: Some("es".into()), - ..Default::default() - }); - let out = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("got {other:?}"), - }; - assert_eq!(out.video.renditions.keys().collect::>(), vec!["hi"]); - assert_eq!(out.audio.renditions.keys().collect::>(), vec!["es"]); - } - - #[test] - fn ends_after_upstream_eof() { - let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); - let mut f = Filter::new(Once(Some(snapshot))); - - // First poll emits the filtered snapshot. - assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(Some(_))))); - // Upstream is exhausted, so the stream ends rather than parking forever. - assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); - - // EOF is terminal: a filter change after the end must not revive the stream. - f.set_video(FilterVideo { - name: Some("hi".into()), - ..Default::default() - }); - assert!(matches!(f.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); - } - - #[test] - fn set_video_after_snapshot_reemits() { - // A live (not-yet-EOF) upstream, so the retarget re-applies to the retained snapshot. - let snapshot = catalog_with(vec![h264("lo"), h264("hi")], vec![]); - let mut f = Filter::new(Live(Some(snapshot))); - - let first = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("got {other:?}"), - }; - assert_eq!(first.video.renditions.len(), 2); - - f.set_video(FilterVideo { - name: Some("hi".into()), - ..Default::default() - }); - - let again = match f.poll_next(&kio::Waiter::noop()) { - Poll::Ready(Ok(Some(c))) => c, - other => panic!("expected re-emit, got {other:?}"), - }; - assert_eq!(again.video.renditions.keys().collect::>(), vec!["hi"]); - } -} diff --git a/rs/moq-mux/src/catalog/format.rs b/rs/moq-mux/src/catalog/format.rs index 8aa1bb5041..357d76082d 100644 --- a/rs/moq-mux/src/catalog/format.rs +++ b/rs/moq-mux/src/catalog/format.rs @@ -12,6 +12,12 @@ pub enum CatalogFormat { /// `hang` JSON catalog (track `catalog.json`). #[default] Hang, + /// DEFLATE-compressed `hang` JSON catalog (track `catalog.json.z`). + /// + /// Same broadcast-name suffix as [`Hang`](Self::Hang) (`.hang`): the compression is a track-level + /// choice, not a different broadcast. Opt in explicitly. [`detect`](Self::detect) never returns + /// this, so name-based auto-detection stays on the uncompressed track until consumers are moved over. + HangZ, /// MSF catalog (track `catalog`). Msf, } @@ -25,7 +31,7 @@ impl CatalogFormat { /// The filename-style suffix (including leading dot) for this format. pub fn extension(self) -> &'static str { match self { - Self::Hang => ".hang", + Self::Hang | Self::HangZ => ".hang", Self::Msf => ".msf", } } diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index 28b7424e5f..33eec75b55 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -15,13 +15,24 @@ pub struct Consumer { } impl Consumer { - /// Create a new catalog consumer from a MoQ track subscriber. + /// Create a new catalog consumer from a MoQ track subscriber (uncompressed `catalog.json`). pub fn new(track: moq_net::TrackSubscriber) -> Self { Self { inner: moq_json::Consumer::new(track, moq_json::ConsumerConfig::default()), } } + /// Create a consumer for the DEFLATE-compressed catalog track (`catalog.json.z`). + /// + /// The track must be the compressed one (see [`hang::Catalog::COMPRESSED_NAME`]). + pub fn compressed(track: moq_net::TrackSubscriber) -> Self { + let mut config = moq_json::ConsumerConfig::default(); + config.compression = true; + Self { + inner: moq_json::Consumer::new(track, config), + } + } + /// Poll for the next catalog update. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { let result = ready!(self.inner.poll_next(waiter)); diff --git a/rs/moq-mux/src/catalog/hang/ext.rs b/rs/moq-mux/src/catalog/hang/ext.rs index fc494eee4a..14a60f46a7 100644 --- a/rs/moq-mux/src/catalog/hang/ext.rs +++ b/rs/moq-mux/src/catalog/hang/ext.rs @@ -193,7 +193,7 @@ mod test { #[test] fn untyped_extra_roundtrip() { let mut broadcast = moq_net::BroadcastInfo::new().produce(); - let mut producer = crate::catalog::Producer::new_extra(&mut broadcast).unwrap(); + let mut producer = crate::catalog::Producer::::with_catalog(&mut broadcast, Catalog::default()).unwrap(); let mut consumer = producer.consume().unwrap(); // A media section (flat field) coexists with an arbitrary untyped application section. @@ -202,12 +202,13 @@ mod test { hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2), ); producer + .lock() .set_section("transcript", serde_json::json!({ "track": "transcript.json" })) .unwrap(); // Reserved media keys can't be smuggled in as application sections. assert!(matches!( - producer.set_section("video", serde_json::json!({})), + producer.lock().set_section("video", serde_json::json!({})), Err(crate::Error::ReservedSection(_)) )); diff --git a/rs/moq-mux/src/catalog/mod.rs b/rs/moq-mux/src/catalog/mod.rs index e0bb25aef8..f7a4a16120 100644 --- a/rs/moq-mux/src/catalog/mod.rs +++ b/rs/moq-mux/src/catalog/mod.rs @@ -15,26 +15,24 @@ //! //! On the consume side, [`Consumer`] is the unified entry point: it //! subscribes to whichever catalog track `format` advertises and yields -//! [`Catalog`](hang::Catalog) snapshots. Wrap it with [`Filter`] (hard -//! match on name / codec family) or [`Target`] (soft match picking one -//! rendition per axis) to narrow the set before handing it to an exporter; -//! both also implement [`Stream`] so they compose either direction. +//! [`Catalog`](hang::Catalog) snapshots. Wrap it with [`Select`] (driven by a +//! [`select::Broadcast`](crate::select::Broadcast)) to narrow the set before +//! handing it to an exporter; both also implement [`Stream`] so they compose +//! either direction. pub mod hang; pub mod msf; mod consumer; -mod filter; mod format; mod producer; +mod select; mod stream; -mod target; mod tracks; pub use consumer::Consumer; -pub use filter::{Filter, FilterAudio, FilterVideo}; pub use format::*; pub use producer::{Guard, Producer}; +pub use select::Select; pub use stream::Stream; -pub use target::{Target, TargetAudio, TargetVideo}; pub use tracks::{AudioTrack, VideoTrack}; diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index 743ffda6be..a039ff3108 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -309,6 +309,17 @@ fn derive_from_codec_config(track: &moq_msf::Track, codec: &AudioCodec, init: by channel_count: cfg.channel_count, }) } + AudioCodec::Flac => { + // The init data is the FLAC header (`fLaC` marker + STREAMINFO). `parse` stops + // after STREAMINFO; trailing metadata blocks (Vorbis comments, etc.) are valid + // and left unread, so unlike Opus there is no trailing-bytes check. + let cfg = + crate::codec::flac::Config::parse(&mut buf).map_err(|_| Error::MalformedFlac(track.name.clone()))?; + Ok(DerivedAudio { + sample_rate: cfg.sample_rate, + channel_count: cfg.channel_count, + }) + } _ => Err(Error::UnsupportedDerivationCodec(track.name.clone()).into()), } } @@ -330,7 +341,7 @@ fn derive_from_cmaf_moov(track: &moq_msf::Track, init: bytes::Bytes) -> Result { hang: moq_json::Producer>, + hangz: moq_json::Producer>, msf_track: moq_net::TrackProducer, current: Arc>>, @@ -37,6 +38,7 @@ impl Clone for Producer { fn clone(&self) -> Self { Self { hang: self.hang.clone(), + hangz: self.hangz.clone(), msf_track: self.msf_track.clone(), current: self.current.clone(), clock: self.clock, @@ -45,41 +47,16 @@ impl Clone for Producer { } impl Producer<()> { - /// Create a new catalog producer with the default (empty) catalog. + /// Create a new media-only catalog producer with the default (empty) catalog. /// - /// To publish an extended catalog, use [`with_catalog`](Self::with_catalog) with a `Catalog`. + /// For an extended catalog, use [`with_catalog`](Self::with_catalog) with a + /// `Catalog` (e.g. the untyped [`Extra`] for the by-name / FFI path). Set + /// application sections through [`lock`](Self::lock). pub fn new(broadcast: &mut moq_net::BroadcastProducer) -> Result { Self::with_catalog(broadcast, Catalog::default()) } } -impl Producer { - /// Create a catalog producer carrying the untyped [`Extra`] extension, so application - /// sections can be set later via [`set_section`](Self::set_section). This is the entry - /// point for callers that work with sections by name (e.g. the FFI boundary); for a typed - /// extension use [`with_catalog`](Self::with_catalog) with a `Catalog`. - pub fn new_extra(broadcast: &mut moq_net::BroadcastProducer) -> Result { - Self::with_catalog(broadcast, Catalog::default()) - } - - /// Set (or replace) a top-level application catalog section, publishing the updated catalog. - /// - /// `value` is any JSON document (object, array, string, ...). Errors if `name` collides with a - /// reserved media section (`video`/`audio`). This is the untyped counterpart to mutating a - /// typed extension through [`lock`](Self::lock), used where section names aren't known at - /// compile time (e.g. across the FFI boundary). - pub fn set_section(&mut self, name: impl Into, value: serde_json::Value) -> crate::Result<()> { - self.lock().set_section(name, value) - } - - /// Remove a top-level application catalog section, publishing the updated catalog if it existed. - /// - /// Returns the section's previous value, or `None` if it was absent. - pub fn remove_section(&mut self, name: &str) -> Option { - self.lock().remove_section(name) - } -} - impl Producer { /// Create a new catalog producer with the given initial catalog. pub fn with_catalog( @@ -87,15 +64,23 @@ impl Producer { catalog: Catalog, ) -> Result { let hang_track = broadcast.create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info())?; + let hangz_track = + broadcast.create_track(hang::Catalog::COMPRESSED_NAME, hang::Catalog::default_track_info())?; let msf_track = broadcast.create_track(moq_msf::DEFAULT_NAME, None)?; // Disable deltas for now to stay byte-compatible with consumers that only read snapshots. let mut json_config = moq_json::ProducerConfig::default(); json_config.delta_ratio = 0; - let hang = moq_json::Producer::new(hang_track, json_config); + let hang = moq_json::Producer::new(hang_track, json_config.clone()); + + // The `.z` track carries the same catalog, DEFLATE-compressed. Deltas stay off for parity + // with the plaintext track; only the per-group compression differs. + json_config.compression = true; + let hangz = moq_json::Producer::new(hangz_track, json_config); Ok(Self { hang, + hangz, msf_track, current: Arc::new(Mutex::new(catalog)), clock: crate::Clock::new(), @@ -119,6 +104,7 @@ impl Producer { Guard { catalog: self.current.lock().unwrap(), hang: &mut self.hang, + hangz: &mut self.hangz, msf_track: &mut self.msf_track, updated: false, } @@ -151,6 +137,7 @@ impl Producer { /// Finish publishing to this catalog. pub fn finish(&mut self) -> crate::Result<()> { self.hang.finish()?; + self.hangz.finish()?; self.msf_track.finish()?; Ok(()) } @@ -161,10 +148,11 @@ impl Producer { /// Obtained via [`Producer::lock`]. Derefs to the [`Catalog`](super::hang::Catalog), so `video`/`audio` /// and (through the catalog's own deref) the extension sections are editable directly. /// -/// On drop, both the hang and MSF catalog tracks are updated if the catalog was mutated. +/// On drop, the hang, compressed-hang, and MSF catalog tracks are updated if the catalog was mutated. pub struct Guard<'a, E: CatalogExt = ()> { catalog: MutexGuard<'a, Catalog>, hang: &'a mut moq_json::Producer>, + hangz: &'a mut moq_json::Producer>, msf_track: &'a mut moq_net::TrackProducer, updated: bool, } @@ -212,9 +200,11 @@ impl Drop for Guard<'_, E> { return; } - // Publish the hang catalog (one snapshot per group while deltas are disabled). + // Publish the hang catalog (one snapshot per group while deltas are disabled), plus its + // DEFLATE-compressed `.z` sibling carrying the identical catalog. let catalog: &Catalog = &self.catalog; let _ = self.hang.update(catalog); + let _ = self.hangz.update(catalog); // Publish the MSF catalog, derived from the base media sections. let msf = to_msf(&self.catalog.media()); @@ -320,11 +310,44 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { mod test { use std::collections::BTreeMap; + use std::task::Poll; + use bytes::Bytes; use hang::catalog::{Audio, AudioCodec, AudioConfig, Container, H264, Video, VideoConfig}; use super::*; + #[test] + fn publishes_plain_and_compressed_tracks() { + let mut broadcast = moq_net::BroadcastInfo::new().produce(); + let mut catalog = Producer::new(&mut broadcast).unwrap(); + + let mut plain = Consumer::new(catalog.hang.consume()); + let mut compressed = Consumer::compressed(catalog.hangz.consume()); + + { + let mut guard = catalog.lock(); + guard + .audio + .renditions + .insert("audio0".to_string(), AudioConfig::new(AudioCodec::Opus, 48_000, 2)); + } + let expected = catalog.snapshot(); + + let waiter = kio::Waiter::noop(); + let got_plain = match plain.poll_next(&waiter) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("expected plain catalog, got {other:?}"), + }; + let got_compressed = match compressed.poll_next(&waiter) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("expected compressed catalog, got {other:?}"), + }; + + assert_eq!(got_plain, expected); + assert_eq!(got_compressed, expected); + } + #[test] fn convert_simple() { let mut video_config = VideoConfig::new(H264 { diff --git a/rs/moq-mux/src/catalog/select.rs b/rs/moq-mux/src/catalog/select.rs new file mode 100644 index 0000000000..febce02d2a --- /dev/null +++ b/rs/moq-mux/src/catalog/select.rs @@ -0,0 +1,88 @@ +//! Rendition-selecting catalog stream. +//! +//! [`Select`] wraps any [`Stream`] and applies a [`select::Broadcast`](crate::select::Broadcast) +//! to each snapshot, dropping renditions that aren't selected before handing the +//! catalog to an exporter. + +use std::task::{Poll, ready}; + +use super::Stream; +use super::hang::Catalog; +use crate::select; + +/// A [`Stream`] that keeps only the renditions a [`select::Broadcast`] selects. +/// +/// The selection is fixed at construction; every snapshot from the inner stream is +/// narrowed by it. Build one with [`Stream::select`](super::Stream::select) or +/// [`Select::new`]. +pub struct Select { + inner: S, + selection: select::Broadcast, +} + +impl Select { + /// Wrap `inner`, narrowing every snapshot by `selection`. + pub fn new(inner: S, selection: select::Broadcast) -> Self { + Self { inner, selection } + } +} + +impl Stream for Select { + type Ext = S::Ext; + + fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { + let next = ready!(self.inner.poll_next(waiter))?; + Poll::Ready(Ok(next.map(|mut catalog| { + self.selection.retain(&mut catalog); + catalog + }))) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use hang::catalog::{Container, H264, VideoConfig}; + + use super::super::hang::Catalog; + use super::*; + + /// A one-shot stream: yields its snapshot once, then ends. + struct Once(Option); + + impl Stream for Once { + type Ext = (); + + fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { + Poll::Ready(Ok(self.0.take())) + } + } + + fn h264(name: &str) -> (String, VideoConfig) { + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + config.container = Container::Legacy; + (name.to_string(), config) + } + + #[test] + fn narrows_each_snapshot() { + let mut catalog = Catalog::default(); + catalog.video.renditions = BTreeMap::from_iter(vec![h264("lo"), h264("hi")]); + + let selection = select::Broadcast::default().video(select::Video::default().name("hi")); + let mut stream = Once(Some(catalog)).select(selection); + + let out = match stream.poll_next(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(c))) => c, + other => panic!("expected snapshot, got {other:?}"), + }; + assert_eq!(out.video.renditions.keys().collect::>(), vec!["hi"]); + assert!(matches!(stream.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); + } +} diff --git a/rs/moq-mux/src/catalog/stream.rs b/rs/moq-mux/src/catalog/stream.rs index 28ac16ec5c..8c4a351c0b 100644 --- a/rs/moq-mux/src/catalog/stream.rs +++ b/rs/moq-mux/src/catalog/stream.rs @@ -2,9 +2,8 @@ //! //! [`Stream`] yields a sequence of [`Catalog`](super::hang::Catalog) snapshots. Both the //! raw [`Consumer`](super::Consumer) and the rendition-selecting -//! [`Filter`](super::Filter) / [`Target`](super::Target) wrappers implement -//! it, so exporters can be written against the trait and the caller picks -//! the selection policy. +//! [`Select`](super::Select) wrapper implement it, so exporters can be written +//! against the trait and the caller picks the selection policy. //! //! The yielded catalog carries the application extension `E` (defaulting to //! `()` for media-only catalogs) via the [`Ext`](Stream::Ext) associated type, @@ -12,8 +11,8 @@ use std::task::Poll; +use super::Select; use super::hang::{Catalog, CatalogExt}; -use super::{Filter, Target}; /// A stream of catalog snapshots. /// @@ -37,21 +36,12 @@ pub trait Stream: Send + 'static { async move { kio::wait(|waiter| self.poll_next(waiter)).await } } - /// Wrap this stream in a [`Filter`] that drops renditions which don't - /// match a hard-match criterion (name or codec family). - fn filter(self) -> Filter + /// Wrap this stream in a [`Select`] that drops every rendition `selection` + /// doesn't keep. + fn select(self, selection: crate::select::Broadcast) -> Select where Self: Sized, { - Filter::new(self) - } - - /// Wrap this stream in a [`Target`] that reduces each axis to at most - /// one rendition by soft-matching against width / height / bitrate. - fn target(self) -> Target - where - Self: Sized, - { - Target::new(self) + Select::new(self, selection) } } diff --git a/rs/moq-mux/src/catalog/target.rs b/rs/moq-mux/src/catalog/target.rs deleted file mode 100644 index 5356660b43..0000000000 --- a/rs/moq-mux/src/catalog/target.rs +++ /dev/null @@ -1,517 +0,0 @@ -//! Soft-match rendition target. -//! -//! [`Target`] wraps any [`Stream`] and reduces each axis (video / audio) to at -//! most one rendition by ranking the input against constraints like maximum -//! width, height, pixels, or bitrate. The ranking algorithm is a Rust port of -//! [js/watch's `#select`](js/watch/src/video/source.ts). - -use std::collections::BTreeMap; -use std::task::Poll; - -use hang::catalog::{AudioConfig, VideoConfig}; - -use super::Stream; -use super::hang::{Catalog, CatalogExt}; - -/// Soft-match constraints for the video rendition. -/// -/// Each `Option` is a *maximum* the selection will try to stay under. When a -/// rendition fits every active maximum, the largest such rendition wins; if -/// nothing fits, the algorithm degrades to the smallest over-budget rendition -/// (per constraint) and intersects across constraints. -#[derive(Debug, Default, Clone)] -pub struct TargetVideo { - pub width: Option, - pub height: Option, - pub pixels: Option, - pub bitrate: Option, -} - -/// Soft-match constraints for the audio rendition. -#[derive(Debug, Default, Clone)] -pub struct TargetAudio { - pub bitrate: Option, -} - -/// Shared state behind a [`Target`]. -/// -/// `epoch` advances on every setter so [`Target::poll_next`] can tell whether -/// the criteria changed since the last emit without diffing the structs. -#[derive(Debug, Default, Clone)] -struct TargetState { - video: Option, - audio: Option, - epoch: u64, -} - -/// A [`Stream`] that picks one rendition per axis from the inner snapshot. -/// -/// Selection criteria live behind a [`kio::Producer`], so calls to -/// [`set_video`](Self::set_video) / [`set_audio`](Self::set_audio) wake any -/// pending `poll_next` instead of silently waiting for the next upstream -/// snapshot. That makes the type usable as the foothold for bandwidth-driven -/// ABR retargeting. -pub struct Target { - inner: S, - state: kio::Producer, - state_consumer: kio::Consumer, - /// Last raw snapshot from `inner`, retained so a target change between - /// snapshots can be re-applied without polling upstream. - last_input: Option>, - /// Epoch we already emitted against. If `state.epoch` advances past this - /// while `last_input` is `Some`, the next poll re-emits. - last_epoch: u64, - /// True once `inner` has handed us a snapshot we haven't emitted yet. - fresh_input: bool, -} - -impl Target { - pub fn new(inner: S) -> Self { - let state = kio::Producer::new(TargetState::default()); - let state_consumer = state.consume(); - Self { - inner, - state, - state_consumer, - last_input: None, - last_epoch: 0, - fresh_input: false, - } - } - - /// Set or clear the video target. Pass `None` to keep every rendition. - pub fn set_video(&mut self, target: impl Into>) { - self.update(|s| s.video = target.into()); - } - - /// Set or clear the audio target. Pass `None` to keep every rendition. - pub fn set_audio(&mut self, target: impl Into>) { - self.update(|s| s.audio = target.into()); - } - - fn update(&self, f: impl FnOnce(&mut TargetState)) { - // `write()` only errors when the producer is closed, which can't happen - // while `self` holds the only producer handle. - let Ok(mut state) = self.state.write() else { - return; - }; - f(&mut state); - state.epoch = state.epoch.wrapping_add(1); - // Mut::drop wakes the paired consumer waiters here. - } -} - -impl Stream for Target { - type Ext = S::Ext; - - fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll>>> { - // Drain inner: the latest snapshot wins. `poll_next` registers the - // waiter on its own Pending branch. - let inner_eof = loop { - match self.inner.poll_next(waiter)? { - Poll::Ready(Some(snapshot)) => { - self.last_input = Some(snapshot); - self.fresh_input = true; - } - Poll::Ready(None) => break true, - Poll::Pending => break false, - } - }; - - // Snapshot the fields the inner closure needs so it can borrow them - // without colliding with the `&self.state_consumer` receiver. - let last_epoch = self.last_epoch; - let fresh_input = self.fresh_input; - let last_input = self.last_input.clone(); - - let polled = self.state_consumer.poll(waiter, |state| { - let target_changed = state.epoch != last_epoch; - if !fresh_input && !target_changed { - // Nothing new from inner and nothing new from caller: register - // the waiter on this consumer so the next setter wakes us. - return Poll::Pending; - } - let Some(input) = last_input.clone() else { - // Caller already retargeted, but no upstream snapshot yet to apply. - return Poll::Pending; - }; - let emit = apply(input, state.video.as_ref(), state.audio.as_ref()); - Poll::Ready((emit, state.epoch)) - }); - - match polled { - Poll::Ready(Ok((emit, epoch))) => { - self.last_epoch = epoch; - self.fresh_input = false; - // End with upstream: if this is the final snapshot (inner already EOF'd), - // drop the retained input so a later retarget can't revive the stream after - // it has emitted its last value. - if inner_eof { - self.last_input = None; - } - Poll::Ready(Ok(Some(emit))) - } - Poll::Ready(Err(_)) => { - // Producer dropped (impossible while Self holds it); treat as EOF. - Poll::Ready(Ok(None)) - } - Poll::Pending => { - // EOF is terminal: once `inner` is exhausted and there's nothing fresh to - // emit, finish and drop the retained input so a post-EOF retarget can't make - // the closure emit again (a still-pending snapshot returns Ready above). - if inner_eof { - self.last_input = None; - Poll::Ready(Ok(None)) - } else { - Poll::Pending - } - } - } - } -} - -/// Apply the active video / audio targets to a raw snapshot, narrowing each -/// axis to at most one rendition. Axes with no target pass through unchanged. -fn apply( - mut catalog: Catalog, - video: Option<&TargetVideo>, - audio: Option<&TargetAudio>, -) -> Catalog { - if let Some(target) = video { - if let Some(name) = select_video(&catalog.video.renditions, target) { - let mut kept = BTreeMap::new(); - if let Some(config) = catalog.video.renditions.remove(&name) { - kept.insert(name, config); - } - catalog.video.renditions = kept; - } else { - catalog.video.renditions.clear(); - } - } - - if let Some(target) = audio { - if let Some(name) = select_audio(&catalog.audio.renditions, target) { - let mut kept = BTreeMap::new(); - if let Some(config) = catalog.audio.renditions.remove(&name) { - kept.insert(name, config); - } - catalog.audio.renditions = kept; - } else { - catalog.audio.renditions.clear(); - } - } - - catalog -} - -/// Run all active video rankings and return the highest-ranked rendition -/// present in every ranking, or `None` if the intersection is empty. -fn select_video(renditions: &BTreeMap, target: &TargetVideo) -> Option { - if renditions.is_empty() { - return None; - } - if renditions.len() == 1 { - return renditions.keys().next().cloned(); - } - - let mut rankings: Vec> = Vec::new(); - if let Some(max) = target.pixels { - rankings.push(by_pixels(renditions, max)); - } - if target.width.is_some() || target.height.is_some() { - rankings.push(by_dimensions(renditions, target.width, target.height)); - } - if let Some(max) = target.bitrate { - rankings.push(by_video_bitrate(renditions, max)); - } - - if rankings.is_empty() { - return Some(best_video(renditions)); - } - - intersect_rankings(rankings) -} - -fn select_audio(renditions: &BTreeMap, target: &TargetAudio) -> Option { - if renditions.is_empty() { - return None; - } - if renditions.len() == 1 { - return renditions.keys().next().cloned(); - } - - let mut rankings: Vec> = Vec::new(); - if let Some(max) = target.bitrate { - rankings.push(by_audio_bitrate(renditions, max)); - } - - if rankings.is_empty() { - return Some(best_audio(renditions)); - } - - intersect_rankings(rankings) -} - -/// Pick the first name from `rankings[0]` that appears in every other ranking. -fn intersect_rankings(rankings: Vec>) -> Option { - use std::collections::HashSet; - let sets: Vec> = rankings.iter().map(|r| r.iter().collect()).collect(); - for name in &rankings[0] { - if sets.iter().all(|s| s.contains(name)) { - return Some(name.clone()); - } - } - tracing::warn!("conflicting rendition targets, no rendition satisfies all criteria"); - None -} - -/// Rank by area, largest-first within budget; fall back to single smallest -/// over-budget if nothing fits. Renditions without resolution metadata are -/// returned unranked when no rendition has any metadata at all (mirrors the JS). -fn by_pixels(renditions: &BTreeMap, max: u32) -> Vec { - let mut within: Vec<(String, u32)> = Vec::new(); - let mut rest: Vec<(String, u32)> = Vec::new(); - - for (name, config) in renditions { - if let (Some(w), Some(h)) = (config.coded_width, config.coded_height) { - let size = w.saturating_mul(h); - if size <= max { - within.push((name.clone(), size)); - } else { - rest.push((name.clone(), size)); - } - } - } - - within.sort_by_key(|b| std::cmp::Reverse(b.1)); - if !within.is_empty() { - return within.into_iter().map(|(n, _)| n).collect(); - } - - rest.sort_by_key(|a| a.1); - if let Some(smallest) = rest.into_iter().next() { - return vec![smallest.0]; - } - - renditions.keys().cloned().collect() -} - -fn by_dimensions(renditions: &BTreeMap, width: Option, height: Option) -> Vec { - let mut within: Vec<(String, u32)> = Vec::new(); - let mut rest: Vec<(String, u32)> = Vec::new(); - - for (name, config) in renditions { - let (Some(w), Some(h)) = (config.coded_width, config.coded_height) else { - continue; - }; - let size = w.saturating_mul(h); - let fits_w = width.is_none_or(|cap| w <= cap); - let fits_h = height.is_none_or(|cap| h <= cap); - if fits_w && fits_h { - within.push((name.clone(), size)); - } else { - rest.push((name.clone(), size)); - } - } - - within.sort_by_key(|b| std::cmp::Reverse(b.1)); - if !within.is_empty() { - return within.into_iter().map(|(n, _)| n).collect(); - } - - rest.sort_by_key(|a| a.1); - if let Some(smallest) = rest.into_iter().next() { - return vec![smallest.0]; - } - - renditions.keys().cloned().collect() -} - -fn by_video_bitrate(renditions: &BTreeMap, max: u64) -> Vec { - let mut within: Vec<(String, u64)> = Vec::new(); - let mut rest: Vec<(String, u64)> = Vec::new(); - for (name, config) in renditions { - if let Some(b) = config.bitrate { - if b <= max { - within.push((name.clone(), b)); - } else { - rest.push((name.clone(), b)); - } - } - } - within.sort_by_key(|b| std::cmp::Reverse(b.1)); - if !within.is_empty() { - return within.into_iter().map(|(n, _)| n).collect(); - } - rest.sort_by_key(|a| a.1); - if let Some(smallest) = rest.into_iter().next() { - return vec![smallest.0]; - } - renditions.keys().cloned().collect() -} - -fn by_audio_bitrate(renditions: &BTreeMap, max: u64) -> Vec { - let mut within: Vec<(String, u64)> = Vec::new(); - let mut rest: Vec<(String, u64)> = Vec::new(); - for (name, config) in renditions { - if let Some(b) = config.bitrate { - if b <= max { - within.push((name.clone(), b)); - } else { - rest.push((name.clone(), b)); - } - } - } - within.sort_by_key(|b| std::cmp::Reverse(b.1)); - if !within.is_empty() { - return within.into_iter().map(|(n, _)| n).collect(); - } - rest.sort_by_key(|a| a.1); - if let Some(smallest) = rest.into_iter().next() { - return vec![smallest.0]; - } - renditions.keys().cloned().collect() -} - -/// With no constraints, prefer the largest resolution then the highest bitrate. -fn best_video(renditions: &BTreeMap) -> String { - renditions - .iter() - .max_by_key(|(_, c)| { - let area = c.coded_width.unwrap_or(0).saturating_mul(c.coded_height.unwrap_or(0)) as u64; - (area, c.bitrate.unwrap_or(0)) - }) - .map(|(n, _)| n.clone()) - .expect("renditions non-empty checked by caller") -} - -fn best_audio(renditions: &BTreeMap) -> String { - renditions - .iter() - .max_by_key(|(_, c)| c.bitrate.unwrap_or(0)) - .map(|(n, _)| n.clone()) - .expect("renditions non-empty checked by caller") -} - -#[cfg(test)] -mod test { - use std::collections::BTreeMap; - - use hang::catalog::{Container, H264, VideoConfig}; - - use super::*; - - /// A one-shot stream: yields its snapshot once, then EOF. - struct Once(Option); - - impl Stream for Once { - type Ext = (); - - fn poll_next(&mut self, _: &kio::Waiter) -> Poll>> { - Poll::Ready(Ok(self.0.take())) - } - } - - /// Once upstream ends and the final selected snapshot is emitted, the stream ends - /// rather than parking forever waiting for a post-EOF retarget. - #[test] - fn ends_after_upstream_eof() { - let mut catalog = Catalog::default(); - catalog.video.renditions = BTreeMap::from_iter(vec![vid("only", 640, 360, 500_000)]); - - let mut t = Target::new(Once(Some(catalog))); - assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(Some(_))))); - assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); - - // EOF is terminal: a retarget after the end must not revive the stream. - t.set_video(TargetVideo { - width: Some(320), - ..Default::default() - }); - assert!(matches!(t.poll_next(&kio::Waiter::noop()), Poll::Ready(Ok(None)))); - } - - fn vid(name: &str, w: u32, h: u32, bitrate: u64) -> (String, VideoConfig) { - let mut config = VideoConfig::new(H264 { - profile: 0x42, - constraints: 0, - level: 0x1e, - inline: false, - }); - config.coded_width = Some(w); - config.coded_height = Some(h); - config.bitrate = Some(bitrate); - config.framerate = Some(30.0); - config.container = Container::Legacy; - (name.to_string(), config) - } - - fn map(items: Vec<(String, VideoConfig)>) -> BTreeMap { - BTreeMap::from_iter(items) - } - - #[test] - fn pick_largest_under_width_cap() { - let renditions = map(vec![ - vid("sd", 640, 360, 500_000), - vid("hd", 1280, 720, 2_500_000), - vid("fhd", 1920, 1080, 6_000_000), - ]); - let target = TargetVideo { - width: Some(1280), - ..Default::default() - }; - assert_eq!(select_video(&renditions, &target).as_deref(), Some("hd")); - } - - #[test] - fn pick_largest_under_bitrate_cap() { - let renditions = map(vec![ - vid("sd", 640, 360, 500_000), - vid("hd", 1280, 720, 2_500_000), - vid("fhd", 1920, 1080, 6_000_000), - ]); - let target = TargetVideo { - bitrate: Some(3_000_000), - ..Default::default() - }; - assert_eq!(select_video(&renditions, &target).as_deref(), Some("hd")); - } - - #[test] - fn degrade_to_smallest_over_budget() { - let renditions = map(vec![vid("hd", 1280, 720, 2_500_000), vid("fhd", 1920, 1080, 6_000_000)]); - let target = TargetVideo { - bitrate: Some(100_000), - ..Default::default() - }; - assert_eq!(select_video(&renditions, &target).as_deref(), Some("hd")); - } - - #[test] - fn no_constraints_picks_largest() { - let renditions = map(vec![ - vid("sd", 640, 360, 500_000), - vid("hd", 1280, 720, 2_500_000), - vid("fhd", 1920, 1080, 6_000_000), - ]); - let target = TargetVideo::default(); - assert_eq!(select_video(&renditions, &target).as_deref(), Some("fhd")); - } - - #[test] - fn width_and_bitrate_intersect() { - let renditions = map(vec![ - vid("sd", 640, 360, 500_000), - vid("hd", 1280, 720, 2_500_000), - vid("fhd", 1920, 1080, 6_000_000), - ]); - let target = TargetVideo { - width: Some(1920), - bitrate: Some(1_000_000), - ..Default::default() - }; - // width allows all, bitrate allows only sd. - assert_eq!(select_video(&renditions, &target).as_deref(), Some("sd")); - } -} diff --git a/rs/moq-mux/src/codec/aac/mod.rs b/rs/moq-mux/src/codec/aac/mod.rs index 49c5c3fc22..e2477911fc 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -224,11 +224,36 @@ mod tests { assert_eq!(cfg.channel_count, 2); } - // TODO: a round-trip test for the explicit-frequency (freq_index=0xF) form - // fails today because the parser reads `channel_config` from byte 1 even - // though ISO 14496-3 §1.6.2.1 puts it *after* the 24-bit explicit sample - // rate. The encoder follows the spec, the parser doesn't. Fixing requires - // a bit-level reader; deferred to a separate PR. + #[test] + fn round_trip_explicit_sample_rate() { + // A non-standard rate (no freq_index) forces the explicit 24-bit form, where + // channelConfiguration lands mid-byte after the rate. A byte-aligned parser + // misreads both fields; the bit reader round-trips them. + let cfg = Config { + profile: 2, + sample_rate: 44_056, // not in the standard table + channel_count: 2, + }; + let encoded = cfg.encode(); + assert_eq!(encoded.len(), 5, "explicit-rate config is 5 bytes"); + + let parsed = Config::parse(&mut encoded.as_ref()).unwrap(); + assert_eq!(parsed.profile, 2); + assert_eq!(parsed.sample_rate, 44_056); + assert_eq!(parsed.channel_count, 2); + } + + #[test] + fn parses_extended_object_type() { + // audioObjectType 31 escapes to a 6-bit extended type. Bytes encode + // AOT=31, ext=4 (-> object_type 36), freq_index=3 (48000), channel_config=2, + // which straddle byte boundaries: 11111 000100 0011 0010 + padding. + let buf: [u8; 3] = [0xF8, 0x86, 0x40]; + let cfg = Config::parse(&mut buf.as_slice()).unwrap(); + assert_eq!(cfg.profile, 36); + assert_eq!(cfg.sample_rate, 48_000); + assert_eq!(cfg.channel_count, 2); + } #[test] fn round_trip_5_1_channels() { diff --git a/rs/moq-mux/src/codec/av1/import.rs b/rs/moq-mux/src/codec/av1/import.rs index 62d88c1995..7a1d1f44c8 100644 --- a/rs/moq-mux/src/codec/av1/import.rs +++ b/rs/moq-mux/src/codec/av1/import.rs @@ -60,8 +60,10 @@ impl Import { pub fn initialize(&mut self, buf: &[u8]) -> Result<()> { let data = buf; - // av1C box starts with 0x81 (marker=1, version=1) per ISO/IEC 14496-15. - if data.len() >= 16 && data[0] == 0x81 { + // av1C box starts with 0x81 (marker=1, version=1) per ISO/IEC 14496-15. Only the + // fixed 4-byte header is read here, so don't gate on a larger size or a short + // out-of-band record falls through to raw-OBU scanning and leaves the config unset. + if data.len() >= 4 && data[0] == 0x81 { self.init_from_av1c(data)?; return Ok(()); } diff --git a/rs/moq-mux/src/codec/av1/split.rs b/rs/moq-mux/src/codec/av1/split.rs index 09946ba94f..b7ffc58fea 100644 --- a/rs/moq-mux/src/codec/av1/split.rs +++ b/rs/moq-mux/src/codec/av1/split.rs @@ -161,6 +161,8 @@ impl Split { /// timestamp. pub fn reset(&mut self) { self.current = Au::default(); + // Drop any buffered partial OBU too, so pre-reset bytes can't leak into the next unit. + self.tail.clear(); } fn pts(&mut self, hint: Option) -> Result { diff --git a/rs/moq-mux/src/codec/flac/import.rs b/rs/moq-mux/src/codec/flac/import.rs new file mode 100644 index 0000000000..bccd45e1f6 --- /dev/null +++ b/rs/moq-mux/src/codec/flac/import.rs @@ -0,0 +1,76 @@ +use super::Config; +use crate::catalog::hang::CatalogExt; +use crate::container::Frame; + +/// FLAC importer. +/// +/// Publishes raw FLAC frames to a single moq track. Build it with +/// [`new`](Self::new), passing the track producer and the +/// [`catalog::Producer`](crate::catalog::Producer) it publishes its rendition into. +/// +/// The STREAMINFO ([`Config`]) is required up front: it becomes the catalog +/// `description` (the `fLaC` marker plus STREAMINFO) so a decoder can initialize +/// from the catalog alone. Each FLAC frame is independently decodable, so every +/// frame handed to [`decode`](Self::decode) is published in its own group and +/// flagged as a keyframe. +pub struct Import { + track: crate::container::Producer, + rendition: crate::catalog::AudioTrack, +} + +impl Import { + /// Publish on an existing track producer, registering the rendition in `catalog`. + pub fn new( + track: moq_net::TrackProducer, + catalog: crate::catalog::Producer, + config: Config, + ) -> crate::Result { + let mut audio = hang::catalog::AudioConfig::new( + hang::catalog::AudioCodec::Flac, + config.sample_rate, + config.channel_count, + ); + audio.container = hang::catalog::Container::Legacy; + audio.description = Some(config.description()); + + tracing::debug!(name = ?track.name(), config = ?audio, "starting track"); + + let mut rendition = catalog.audio_track(track.name()); + rendition.set(audio); + + Ok(Self { + track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), + rendition, + }) + } + + /// A watch-only handle to this track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + self.track.track().demand() + } + + /// Finish the track, flushing the current group. + pub fn finish(&mut self) -> crate::Result<()> { + self.track.finish()?; + Ok(()) + } + + /// Close the current group and open the next one at `sequence`. + pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { + self.track.seek(sequence)?; + Ok(()) + } + + /// Publish one FLAC frame as its own group, stamping `pts` or a wall clock when absent. + pub fn decode(&mut self, frame: &[u8], pts: Option) -> crate::Result<()> { + let timestamp = self.rendition.timestamp(pts)?; + self.track.write(Frame { + timestamp, + payload: bytes::Bytes::copy_from_slice(frame), + keyframe: true, + duration: None, + })?; + self.track.finish_group()?; + Ok(()) + } +} diff --git a/rs/moq-mux/src/codec/flac/mod.rs b/rs/moq-mux/src/codec/flac/mod.rs new file mode 100644 index 0000000000..267bcc4f97 --- /dev/null +++ b/rs/moq-mux/src/codec/flac/mod.rs @@ -0,0 +1,222 @@ +//! FLAC. +//! +//! Parse and serialize the FLAC `STREAMINFO` metadata block (RFC 9639 §8.2), +//! the codec's configuration record. [`Config::description`] builds the +//! WebCodecs `description` (the `fLaC` stream marker followed by the STREAMINFO +//! block) so a browser can initialize a decoder from the catalog alone. +//! [`Import`] publishes raw FLAC frames to a moq broadcast. + +mod import; + +pub use import::*; + +use bytes::{Buf, BufMut, Bytes}; + +/// The four-byte stream marker that opens a native FLAC bitstream and the +/// WebCodecs `description`. +const MARKER: [u8; 4] = *b"fLaC"; + +/// Length of a STREAMINFO metadata block body in bytes (RFC 9639 §8.2). +const STREAMINFO_LEN: usize = 34; + +/// FLAC parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The buffer ended before a full STREAMINFO (or its enclosing header) could be read. + #[error("buffer too short for FLAC STREAMINFO")] + Short, + + /// A FLAC header did not begin with the `fLaC` stream marker. + #[error("invalid FLAC stream marker")] + InvalidMarker, + + /// The first metadata block was not STREAMINFO, which RFC 9639 §8.1 requires. + #[error("first metadata block is not STREAMINFO")] + MissingStreamInfo, +} + +pub type Result = std::result::Result; + +/// Typed FLAC configuration mirroring the fields of a STREAMINFO metadata block +/// (RFC 9639 §8.2). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + /// Minimum block size in samples. + pub min_block_size: u16, + /// Maximum block size in samples. + pub max_block_size: u16, + /// Minimum frame size in bytes (0 = unknown). + pub min_frame_size: u32, + /// Maximum frame size in bytes (0 = unknown). + pub max_frame_size: u32, + /// Sample rate in Hz (20-bit field, so 1..=1_048_575). + pub sample_rate: u32, + /// Channel count (1..=8). + pub channel_count: u32, + /// Bits per sample (4..=32). + pub bits_per_sample: u32, + /// Total interchannel samples (36-bit field, 0 = unknown). + pub total_samples: u64, + /// MD5 of the unencoded audio (all-zero = unknown). + pub md5: [u8; 16], +} + +impl Config { + /// Parse a 34-byte STREAMINFO body (RFC 9639 §8.2), without the `fLaC` + /// marker or metadata block header. + pub fn parse_stream_info(buf: &mut T) -> Result { + if buf.remaining() < STREAMINFO_LEN { + return Err(Error::Short); + } + + let min_block_size = buf.get_u16(); + let max_block_size = buf.get_u16(); + let min_frame_size = buf.get_uint(3) as u32; + let max_frame_size = buf.get_uint(3) as u32; + + // Sample rate (20), channels-1 (3), bits-1 (5), and total samples (36) are + // packed into a single 64-bit field. + let packed = buf.get_u64(); + let sample_rate = (packed >> 44) as u32; + let channel_count = (((packed >> 41) & 0x7) as u32) + 1; + let bits_per_sample = (((packed >> 36) & 0x1f) as u32) + 1; + let total_samples = packed & 0xF_FFFF_FFFF; + + let mut md5 = [0u8; 16]; + buf.copy_to_slice(&mut md5); + + Ok(Self { + min_block_size, + max_block_size, + min_frame_size, + max_frame_size, + sample_rate, + channel_count, + bits_per_sample, + total_samples, + md5, + }) + } + + /// Parse a FLAC header: the `fLaC` stream marker followed by the STREAMINFO + /// metadata block. This is the Matroska `A_FLAC` CodecPrivate / WebCodecs + /// `description` layout. Trailing metadata blocks (Vorbis comments, etc.) are + /// ignored. + pub fn parse(buf: &mut T) -> Result { + if buf.remaining() < 4 { + return Err(Error::Short); + } + let mut marker = [0u8; 4]; + buf.copy_to_slice(&mut marker); + if marker != MARKER { + return Err(Error::InvalidMarker); + } + + if buf.remaining() < 4 { + return Err(Error::Short); + } + // Metadata block header: 1-bit last flag, 7-bit block type, 24-bit length. + let header = buf.get_u32(); + let block_type = ((header >> 24) & 0x7f) as u8; + if block_type != 0 { + return Err(Error::MissingStreamInfo); + } + + Self::parse_stream_info(buf) + } + + /// Serialize the 34-byte STREAMINFO body. + pub fn encode_stream_info(&self) -> Bytes { + let mut buf = Vec::with_capacity(STREAMINFO_LEN); + buf.put_u16(self.min_block_size); + buf.put_u16(self.max_block_size); + buf.put_uint(self.min_frame_size as u64, 3); + buf.put_uint(self.max_frame_size as u64, 3); + + let packed = ((self.sample_rate as u64 & 0xF_FFFF) << 44) + | ((self.channel_count.saturating_sub(1) as u64 & 0x7) << 41) + | ((self.bits_per_sample.saturating_sub(1) as u64 & 0x1f) << 36) + | (self.total_samples & 0xF_FFFF_FFFF); + buf.put_u64(packed); + buf.put_slice(&self.md5); + + buf.into() + } + + /// Build the WebCodecs `description`: the `fLaC` stream marker, a metadata + /// block header flagged as the final block, and the STREAMINFO body. This is + /// the `description` a FLAC `AudioDecoder` expects per the WebCodecs FLAC + /// registration. + pub fn description(&self) -> Bytes { + let stream_info = self.encode_stream_info(); + let mut buf = Vec::with_capacity(MARKER.len() + 4 + stream_info.len()); + buf.put_slice(&MARKER); + // Last-metadata-block flag (0x80) set, block type 0 (STREAMINFO). + buf.put_u8(0x80); + buf.put_uint(stream_info.len() as u64, 3); + buf.put_slice(&stream_info); + buf.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Config { + Config { + min_block_size: 4608, + max_block_size: 4608, + min_frame_size: 16, + max_frame_size: 9102, + sample_rate: 44_100, + channel_count: 2, + bits_per_sample: 24, + total_samples: 120_832, + md5: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + } + } + + #[test] + fn stream_info_roundtrip() { + let cfg = sample(); + let encoded = cfg.encode_stream_info(); + assert_eq!(encoded.len(), STREAMINFO_LEN); + let parsed = Config::parse_stream_info(&mut encoded.as_ref()).unwrap(); + assert_eq!(parsed, cfg); + } + + #[test] + fn description_roundtrip() { + let cfg = sample(); + let desc = cfg.description(); + // "fLaC" + 4-byte block header + 34-byte STREAMINFO. + assert_eq!(desc.len(), 4 + 4 + STREAMINFO_LEN); + assert_eq!(&desc[..4], b"fLaC"); + // Final block flag set, block type 0 (STREAMINFO). + assert_eq!(desc[4], 0x80); + + // The description parses back into the same config. + let parsed = Config::parse(&mut desc.as_ref()).unwrap(); + assert_eq!(parsed, cfg); + } + + #[test] + fn parse_rejects_bad_marker() { + let mut desc = sample().description().to_vec(); + desc[0] = b'X'; + assert!(matches!(Config::parse(&mut desc.as_slice()), Err(Error::InvalidMarker))); + } + + #[test] + fn parse_rejects_non_streaminfo_first_block() { + let mut desc = sample().description().to_vec(); + // Flip the block type away from 0 (STREAMINFO) while keeping the marker. + desc[4] = 0x80 | 0x04; // last block, type 4 (Vorbis comment) + assert!(matches!( + Config::parse(&mut desc.as_slice()), + Err(Error::MissingStreamInfo) + )); + } +} diff --git a/rs/moq-mux/src/codec/h264/export.rs b/rs/moq-mux/src/codec/h264/export.rs index 0eead78466..45b29faf00 100644 --- a/rs/moq-mux/src/codec/h264/export.rs +++ b/rs/moq-mux/src/codec/h264/export.rs @@ -53,11 +53,10 @@ struct Avc1Convert { impl Export { /// Subscribe to `broadcast` and emit an Annex-B H.264 byte stream. /// - /// `catalog` is expected to be narrowed to a single H.264 rendition (e.g. - /// `consumer.filter()` with `codec = H264` then `.target()` for ABR - /// selection). Renditions of other codecs are ignored; if multiple H.264 - /// renditions appear in a snapshot, the first by BTreeMap order wins and - /// a warning is logged. + /// `catalog` is expected to be narrowed to a single H.264 rendition by name (e.g. + /// `consumer.select(select::Broadcast::default().video(select::Video::default().name("hd")))`). + /// Renditions of other codecs are ignored; if multiple H.264 renditions appear + /// in a snapshot, the first by BTreeMap order wins and a warning is logged. pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { Self { broadcast, @@ -133,7 +132,7 @@ impl Export { tracing::warn!( count = picked.len(), "multiple H.264 renditions in catalog snapshot; using the first by name. \ - Narrow with catalog::Target to pick one explicitly." + Narrow with catalog::Select to pick one explicitly." ); } @@ -154,7 +153,7 @@ impl Export { let convert = match config.description.as_ref().filter(|d| !d.is_empty()) { None => None, Some(avcc) => { - let params = super::parse_avcc_param_sets(avcc)?; + let params = super::Avcc::parse(avcc)?; if params.sps.is_empty() || params.pps.is_empty() { return Err(super::Error::MissingParamSets { name: name.clone(), diff --git a/rs/moq-mux/src/codec/h264/import.rs b/rs/moq-mux/src/codec/h264/import.rs index 32beb2a486..5512ab098a 100644 --- a/rs/moq-mux/src/codec/h264/import.rs +++ b/rs/moq-mux/src/codec/h264/import.rs @@ -72,8 +72,10 @@ impl Import { } fn initialize_avc1(&mut self, avcc_bytes: &[u8]) -> Result<()> { - self.avc1 = true; + // Only switch to avc1 mode once the avcC actually parses, so a parse failure leaves the + // importer in avc3 mode where inline-SPS keyframes still self-initialize. let avcc = super::Avcc::parse(avcc_bytes)?; + self.avc1 = true; let mut config = hang::catalog::VideoConfig::new(hang::catalog::H264 { profile: avcc.profile, diff --git a/rs/moq-mux/src/codec/h264/mod.rs b/rs/moq-mux/src/codec/h264/mod.rs index de3cd1e6fe..6170dd43b3 100644 --- a/rs/moq-mux/src/codec/h264/mod.rs +++ b/rs/moq-mux/src/codec/h264/mod.rs @@ -163,12 +163,20 @@ impl Sps { /// avcC bytes are still what gets stored as the catalog `description`; this /// struct is for the field extraction. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct Avcc { + /// AVC profile indication (`profile_idc`) from the record. pub profile: u8, + /// Packed constraint-set flags byte from the record. pub constraints: u8, + /// AVC level indication (`level_idc`) from the record. pub level: u8, /// NALU length size in bytes (typically 4). pub length_size: usize, + /// SPS NAL units carried out-of-band in the record. + pub sps: Vec, + /// PPS NAL units carried out-of-band in the record. + pub pps: Vec, /// Resolution from the embedded SPS, if one was present and parseable. pub coded_width: Option, pub coded_height: Option, @@ -177,7 +185,7 @@ pub struct Avcc { impl Avcc { /// Parse an AVCDecoderConfigurationRecord buffer. pub fn parse(avcc: &[u8]) -> Result { - if avcc.len() < 6 { + if avcc.len() < 7 { return Err(Error::AvccTooShort); } @@ -185,20 +193,26 @@ impl Avcc { let constraints = avcc[2]; let level = avcc[3]; let length_size = (avcc[4] & 0x03) as usize + 1; - let num_sps = avcc[5] & 0x1f; + let num_sps = (avcc[5] & 0x1f) as usize; + + let mut pos = 6; + let sps = read_param_sets(avcc, &mut pos, num_sps)?; + + if avcc.len() <= pos { + return Err(Error::AvccTruncated); + } + let num_pps = avcc[pos] as usize; + pos += 1; + let pps = read_param_sets(avcc, &mut pos, num_pps)?; + // Resolution from the first parseable SPS. let (mut coded_width, mut coded_height) = (None, None); - if num_sps > 0 && avcc.len() >= 8 { - let sps_len = u16::from_be_bytes([avcc[6], avcc[7]]) as usize; - let sps_start = 8; - let sps_end = sps_start + sps_len; - if sps_end <= avcc.len() - && sps_len > 1 - && let Ok(sps) = Sps::parse(&avcc[sps_start..sps_end]) - { - coded_width = Some(sps.coded_width); - coded_height = Some(sps.coded_height); - } + if let Some(first) = sps.first() + && first.len() > 1 + && let Ok(parsed) = Sps::parse(first) + { + coded_width = Some(parsed.coded_width); + coded_height = Some(parsed.coded_height); } Ok(Self { @@ -206,6 +220,8 @@ impl Avcc { constraints, level, length_size, + sps, + pps, coded_width, coded_height, }) @@ -273,37 +289,6 @@ pub(crate) fn build_avcc(sps_nals: &[Bytes], pps_nals: &[Bytes]) -> Result, - pub pps: Vec, -} - -/// Pull the SPS and PPS NAL units out of an AVCDecoderConfigurationRecord. -pub fn parse_avcc_param_sets(avcc: &[u8]) -> Result { - if avcc.len() < 7 { - return Err(Error::AvccTooShort); - } - let length_size = (avcc[4] & 0x03) as usize + 1; - let num_sps = (avcc[5] & 0x1f) as usize; - - let mut pos = 6; - let sps = read_param_sets(avcc, &mut pos, num_sps)?; - - if avcc.len() <= pos { - return Err(Error::AvccTruncated); - } - let num_pps = avcc[pos] as usize; - pos += 1; - - let pps = read_param_sets(avcc, &mut pos, num_pps)?; - - Ok(AvccParamSets { length_size, sps, pps }) -} - /// Read `count` length-prefixed (u16) NAL units from `buf` starting at `*pos`, /// advancing `*pos` past the last one. All arithmetic is checked so malformed /// configs surface as errors rather than panics. diff --git a/rs/moq-mux/src/codec/h265/export.rs b/rs/moq-mux/src/codec/h265/export.rs index b65d9dda19..ee14fbe1a5 100644 --- a/rs/moq-mux/src/codec/h265/export.rs +++ b/rs/moq-mux/src/codec/h265/export.rs @@ -124,7 +124,7 @@ impl Export { tracing::warn!( count = picked.len(), "multiple H.265 renditions in catalog snapshot; using the first by name. \ - Narrow with catalog::Target to pick one explicitly." + Narrow with catalog::Select to pick one explicitly." ); } @@ -145,7 +145,7 @@ impl Export { let convert = match config.description.as_ref().filter(|d| !d.is_empty()) { None => None, Some(hvcc) => { - let params = super::parse_hvcc_param_sets(hvcc)?; + let params = super::Hvcc::parse(hvcc)?; if params.vps.is_empty() || params.sps.is_empty() || params.pps.is_empty() { return Err(super::Error::MissingParamSets { name: name.clone(), diff --git a/rs/moq-mux/src/codec/h265/mod.rs b/rs/moq-mux/src/codec/h265/mod.rs index edae02cc21..23c7a5836e 100644 --- a/rs/moq-mux/src/codec/h265/mod.rs +++ b/rs/moq-mux/src/codec/h265/mod.rs @@ -71,66 +71,74 @@ pub enum Error { pub type Result = std::result::Result; -/// VPS, SPS, and PPS NAL units extracted from an hvcC. +/// The parameter sets carried out-of-band in an HEVCDecoderConfigurationRecord, +/// split by NAL type. #[derive(Debug, Clone)] -pub struct HvccParamSets { +#[non_exhaustive] +pub struct Hvcc { /// NALU length size in bytes (typically 4). pub length_size: usize, + /// VPS NAL units carried out-of-band in the record. pub vps: Vec, + /// SPS NAL units carried out-of-band in the record. pub sps: Vec, + /// PPS NAL units carried out-of-band in the record. pub pps: Vec, } -/// Pull the VPS/SPS/PPS NAL units out of an HEVCDecoderConfigurationRecord. -pub fn parse_hvcc_param_sets(hvcc: &[u8]) -> Result { - if hvcc.len() < 23 { - return Err(Error::HvccTooShort); - } - let length_size = (hvcc[21] & 0x3) as usize + 1; - let num_arrays = hvcc[22] as usize; - - let mut vps = Vec::new(); - let mut sps = Vec::new(); - let mut pps = Vec::new(); - let mut pos: usize = 23; - - for _ in 0..num_arrays { - let after_hdr = pos.checked_add(3).ok_or(Error::HvccTruncated)?; - if hvcc.len() < after_hdr { - return Err(Error::HvccTruncated); +impl Hvcc { + /// Parse an HEVCDecoderConfigurationRecord, sorting the VPS/SPS/PPS NAL units + /// by type. The HEVC analogue of [`super::h264::Avcc::parse`]. + pub fn parse(hvcc: &[u8]) -> Result { + if hvcc.len() < 23 { + return Err(Error::HvccTooShort); } - let nal_type = hvcc[pos] & 0x3f; - let num_nalus = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize; - pos = after_hdr; + let length_size = (hvcc[21] & 0x3) as usize + 1; + let num_arrays = hvcc[22] as usize; - for _ in 0..num_nalus { - let after_len = pos.checked_add(2).ok_or(Error::HvccTruncated)?; - if hvcc.len() < after_len { - return Err(Error::HvccTruncated); - } - let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; - let after_nal = after_len.checked_add(len).ok_or(Error::HvccTruncated)?; - if hvcc.len() < after_nal { + let mut vps = Vec::new(); + let mut sps = Vec::new(); + let mut pps = Vec::new(); + let mut pos: usize = 23; + + for _ in 0..num_arrays { + let after_hdr = pos.checked_add(3).ok_or(Error::HvccTruncated)?; + if hvcc.len() < after_hdr { return Err(Error::HvccTruncated); } - let bytes = Bytes::copy_from_slice(&hvcc[after_len..after_nal]); - pos = after_nal; - - match NALUnitType::from(nal_type) { - NALUnitType::VpsNut => vps.push(bytes), - NALUnitType::SpsNut => sps.push(bytes), - NALUnitType::PpsNut => pps.push(bytes), - _ => {} + let nal_type = hvcc[pos] & 0x3f; + let num_nalus = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize; + pos = after_hdr; + + for _ in 0..num_nalus { + let after_len = pos.checked_add(2).ok_or(Error::HvccTruncated)?; + if hvcc.len() < after_len { + return Err(Error::HvccTruncated); + } + let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; + let after_nal = after_len.checked_add(len).ok_or(Error::HvccTruncated)?; + if hvcc.len() < after_nal { + return Err(Error::HvccTruncated); + } + let bytes = Bytes::copy_from_slice(&hvcc[after_len..after_nal]); + pos = after_nal; + + match NALUnitType::from(nal_type) { + NALUnitType::VpsNut => vps.push(bytes), + NALUnitType::SpsNut => sps.push(bytes), + NALUnitType::PpsNut => pps.push(bytes), + _ => {} + } } } - } - Ok(HvccParamSets { - length_size, - vps, - sps, - pps, - }) + Ok(Self { + length_size, + vps, + sps, + pps, + }) + } } /// Build a catalog [`VideoConfig`](hang::catalog::VideoConfig) for the `hvc1` @@ -142,7 +150,7 @@ pub fn parse_hvcc_param_sets(hvcc: &[u8]) -> Result { /// already length-prefixed NALU, so the record passes straight through as the /// catalog `description` (`in_band: false`). pub(crate) fn config_from_hvcc(hvcc: &[u8]) -> Result { - let params = parse_hvcc_param_sets(hvcc)?; + let params = Hvcc::parse(hvcc)?; let sps_nal = params.sps.first().ok_or(Error::MissingSps)?; let sps = SpsNALUnit::parse(&mut &sps_nal[..]).map_err(|_| Error::SpsParse)?; let profile = &sps.rbsp.profile_tier_level.general_profile; diff --git a/rs/moq-mux/src/codec/mod.rs b/rs/moq-mux/src/codec/mod.rs index 2563268a05..2e9e7f93e4 100644 --- a/rs/moq-mux/src/codec/mod.rs +++ b/rs/moq-mux/src/codec/mod.rs @@ -11,10 +11,12 @@ pub(crate) mod ac3; pub mod annexb; pub mod av1; pub(crate) mod eac3; +pub mod flac; pub mod h264; pub mod h265; pub(crate) mod legacy; pub(crate) mod mp2; +pub mod mp3; pub mod opus; pub mod vp8; pub mod vp9; diff --git a/rs/moq-mux/src/codec/mp3.rs b/rs/moq-mux/src/codec/mp3.rs new file mode 100644 index 0000000000..8cc4b4c982 --- /dev/null +++ b/rs/moq-mux/src/codec/mp3.rs @@ -0,0 +1,209 @@ +//! MP3 (MPEG-1/2/2.5 Audio Layer III). +//! +//! Audio carried verbatim: each frame is published whole. The header is parsed +//! only for the catalog config (sample rate, channels); the audio is never +//! decoded and there is no out-of-band configuration record. [`Import`] publishes +//! raw MP3 frames to a moq broadcast. + +use crate::catalog::hang::CatalogExt; +use crate::container::Frame; +use moq_net::Timestamp; + +/// MP3 parsing errors. +#[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The buffer was shorter than the 4-byte MPEG audio frame header. + #[error("MP3 frame header must be at least 4 bytes")] + HeaderTooShort, + + /// The 11-bit frame sync (`0xFFE`) was missing. + #[error("missing MP3 frame sync")] + MissingSync, + + /// The MPEG version field was the reserved value `01`. + #[error("reserved MPEG version")] + ReservedVersion, + + /// The layer field was not Layer III, so this is not an MP3 frame (Layer I/II + /// are MP1/MP2, the reserved value is invalid). + #[error("not an MPEG Layer III (MP3) frame")] + NotLayer3, + + /// The sample-rate index was the reserved value `11`. + #[error("reserved MP3 sample rate")] + ReservedSampleRate, +} + +pub type Result = std::result::Result; + +/// Typed MP3 configuration parsed from an MPEG audio frame header. +pub struct Config { + /// Sampling frequency in Hz. + pub sample_rate: u32, + /// Channel count (1 for the mono channel mode, 2 otherwise). + pub channel_count: u32, +} + +impl Config { + /// Parse the catalog config from the start of an MPEG Layer III frame. + /// + /// Reads the 4-byte frame header (ISO/IEC 11172-3 §2.4.1.2): verifies the + /// frame sync and that the layer is III, then derives the sample rate from + /// the version + sample-rate index and the channel count from the channel + /// mode. The buffer is not advanced; the frame is published whole. + pub fn parse(data: &[u8]) -> Result { + if data.len() < 4 { + return Err(Error::HeaderTooShort); + } + + // 11-bit frame sync: all of byte 0 plus the top 3 bits of byte 1. + if data[0] != 0xFF || (data[1] & 0xE0) != 0xE0 { + return Err(Error::MissingSync); + } + + let version = (data[1] >> 3) & 0x03; + let layer = (data[1] >> 1) & 0x03; + // Layer is encoded inverted: 0b01 == Layer III. + if layer != 0b01 { + return Err(Error::NotLayer3); + } + + let sr_index = ((data[2] >> 2) & 0x03) as usize; + if sr_index == 0b11 { + return Err(Error::ReservedSampleRate); + } + + let sample_rate = match version { + 0b11 => [44100, 48000, 32000][sr_index], // MPEG-1 + 0b10 => [22050, 24000, 16000][sr_index], // MPEG-2 + 0b00 => [11025, 12000, 8000][sr_index], // MPEG-2.5 + _ => return Err(Error::ReservedVersion), + }; + + // Channel mode 0b11 is single channel (mono); the rest are two-channel. + let channel_count = if (data[3] >> 6) & 0x03 == 0b11 { 1 } else { 2 }; + + Ok(Self { + sample_rate, + channel_count, + }) + } +} + +/// MP3 importer. +/// +/// Publishes raw MP3 frames to a single moq track. Build it with [`new`](Self::new), +/// passing the track producer and the [`catalog::Producer`](crate::catalog::Producer) +/// it publishes its rendition into. +/// +/// Each frame handed to [`decode`](Self::decode) is published in its own group so the +/// relay can forward it immediately. MP3 carries its config in band, so the rendition +/// has no out-of-band description. +pub struct Import { + track: crate::container::Producer, + rendition: crate::catalog::AudioTrack, +} + +impl Import { + /// Publish on an existing track producer, registering the rendition in `catalog`. + pub fn new( + track: moq_net::TrackProducer, + catalog: crate::catalog::Producer, + config: Config, + ) -> crate::Result { + let mut audio = + hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Mp3, config.sample_rate, config.channel_count); + audio.container = hang::catalog::Container::Legacy; + + tracing::debug!(name = ?track.name(), config = ?audio, "starting track"); + + let mut rendition = catalog.audio_track(track.name()); + rendition.set(audio); + + Ok(Self { + track: crate::container::Producer::new(track, crate::catalog::hang::Container::Legacy), + rendition, + }) + } + + /// A watch-only handle to this track's subscriber demand. + pub fn demand(&self) -> moq_net::TrackDemand { + self.track.track().demand() + } + + /// Finish the track, flushing the current group. + pub fn finish(&mut self) -> crate::Result<()> { + self.track.finish()?; + Ok(()) + } + + /// Close the current group and open the next one at `sequence`. + pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { + self.track.seek(sequence)?; + Ok(()) + } + + /// Publish one MP3 frame as its own group, stamping `pts` or a wall clock when absent. + pub fn decode(&mut self, frame: &[u8], pts: Option) -> crate::Result<()> { + let timestamp = self.rendition.timestamp(pts)?; + self.track.write(Frame { + timestamp, + payload: bytes::Bytes::copy_from_slice(frame), + keyframe: true, + duration: None, + })?; + self.track.finish_group()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_mpeg1_stereo() { + // MPEG-1 Layer III, 128 kbps, 44.1 kHz, joint stereo. + let header = [0xFF, 0xFB, 0x90, 0x44]; + let cfg = Config::parse(&header).unwrap(); + assert_eq!(cfg.sample_rate, 44100); + assert_eq!(cfg.channel_count, 2); + } + + #[test] + fn parses_mpeg1_mono() { + // Same header but channel mode 0b11 (mono) in the top bits of byte 3. + let header = [0xFF, 0xFB, 0x90, 0xC4]; + let cfg = Config::parse(&header).unwrap(); + assert_eq!(cfg.channel_count, 1); + } + + #[test] + fn parses_mpeg2_sample_rate() { + // MPEG-2 (version 0b10), Layer III, sample-rate index 0 -> 22.05 kHz. + let header = [0xFF, 0xF3, 0x90, 0x44]; + let cfg = Config::parse(&header).unwrap(); + assert_eq!(cfg.sample_rate, 22050); + } + + #[test] + fn rejects_layer2() { + // Layer II is 0b10, i.e. an MP2 (not MP3) frame. + let header = [0xFF, 0xFD, 0x90, 0x44]; + assert!(matches!(Config::parse(&header), Err(Error::NotLayer3))); + } + + #[test] + fn rejects_missing_sync() { + assert!(matches!( + Config::parse(&[0x00, 0x00, 0x00, 0x00]), + Err(Error::MissingSync) + )); + } + + #[test] + fn rejects_short() { + assert!(matches!(Config::parse(&[0xFF, 0xFB]), Err(Error::HeaderTooShort))); + } +} diff --git a/rs/moq-mux/src/codec/opus/import.rs b/rs/moq-mux/src/codec/opus/import.rs index 9fe97367f4..15c6c9d7fb 100644 --- a/rs/moq-mux/src/codec/opus/import.rs +++ b/rs/moq-mux/src/codec/opus/import.rs @@ -41,6 +41,11 @@ impl Import { }) } + /// The MoQ track name this importer publishes on. + pub fn name(&self) -> &str { + self.track.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/opus/mod.rs b/rs/moq-mux/src/codec/opus/mod.rs index 08c2f3e181..12854e22d0 100644 --- a/rs/moq-mux/src/codec/opus/mod.rs +++ b/rs/moq-mux/src/codec/opus/mod.rs @@ -72,7 +72,7 @@ impl Config { /// 0, zero pre-skip and gain). /// /// Errors with [`Error::UnsupportedChannelCount`] unless `channel_count` is 1 - /// or 2 — mapping family 0 is only defined for mono/stereo per RFC 7845 §5.1. + /// or 2, since mapping family 0 is only defined for mono/stereo per RFC 7845 §5.1. /// Multi-channel streams need family 1 with a channel mapping table, which /// this helper does not emit. pub fn encode(&self) -> Result { @@ -91,10 +91,60 @@ impl Config { } } +/// Number of 48 kHz samples in an Opus packet, read from its TOC byte (RFC 6716 §3.1). +/// +/// MPEG-TS aggregates several Opus packets into one PES, so the importer advances each +/// packet's timestamp by this. Opus timing is always reckoned at 48 kHz regardless of the +/// encoder's internal bandwidth. Returns `None` for an empty packet or a code-3 packet +/// missing its frame-count byte. +pub(crate) fn packet_samples(packet: &[u8]) -> Option { + let toc = *packet.first()?; + let frames = match toc & 0b11 { + 0 => 1, + 1 | 2 => 2, + // Code 3: the frame count is the low 6 bits of the following byte. + _ => (packet.get(1)? & 0b0011_1111) as u32, + }; + Some(config_samples(toc >> 3) * frames) +} + +/// 48 kHz samples per frame for an Opus TOC config index (0..=31), per RFC 6716 Table 1. +fn config_samples(config: u8) -> u32 { + match config { + // SILK NB/MB/WB: 10, 20, 40, 60 ms. + 0 | 4 | 8 => 480, + 1 | 5 | 9 => 960, + 2 | 6 | 10 => 1920, + 3 | 7 | 11 => 2880, + // Hybrid SWB/FB: 10, 20 ms. + 12 | 14 => 480, + 13 | 15 => 960, + // CELT NB/WB/SWB/FB: 2.5, 5, 10, 20 ms. + 16 | 20 | 24 | 28 => 120, + 17 | 21 | 25 | 29 => 240, + 18 | 22 | 26 | 30 => 480, + // 19, 23, 27, 31 are the 20 ms CELT configs. + _ => 960, + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn packet_samples_reads_toc() { + // config 16 (CELT NB 2.5 ms = 120 samples), code 0 (1 frame). + assert_eq!(packet_samples(&[16 << 3]), Some(120)); + // config 3 (SILK NB 60 ms = 2880), code 0. + assert_eq!(packet_samples(&[3 << 3]), Some(2880)); + // config 1 (SILK NB 20 ms = 960), code 1 (2 frames) -> 1920. + assert_eq!(packet_samples(&[(1 << 3) | 1]), Some(1920)); + // config 1, code 3 with 4 frames -> 3840. + assert_eq!(packet_samples(&[(1 << 3) | 3, 4]), Some(3840)); + assert_eq!(packet_samples(&[]), None); + } + #[test] fn parses_valid_opus_head() { let cfg = Config { diff --git a/rs/moq-mux/src/container/flv/export.rs b/rs/moq-mux/src/container/flv/export.rs index 6af0a5f9df..d620d30938 100644 --- a/rs/moq-mux/src/container/flv/export.rs +++ b/rs/moq-mux/src/container/flv/export.rs @@ -2,9 +2,9 @@ //! //! [`Export`] subscribes to a MoQ broadcast and produces a single FLV byte //! stream: the file header, the video/audio sequence headers, then one tag per -//! media frame interleaved by timestamp. Legacy H.264 + AAC are muxed as the -//! classic CodecID tags; HEVC, AV1, VP9, Opus, AC-3, and E-AC-3 are muxed as the -//! enhanced-RTMP (E-RTMP) FourCC payloads. Frames flow through [`ExportSource`], +//! media frame interleaved by timestamp. Legacy H.264 + AAC + MP3 are muxed as +//! the classic CodecID tags; HEVC, AV1, VP9, Opus, AC-3, and E-AC-3 are muxed as +//! the enhanced-RTMP (E-RTMP) FourCC payloads. Frames flow through [`ExportSource`], //! which normalizes H.264/H.265 to length-prefixed NALU plus a resolved //! avcC/hvcC (parsing inline avc3/hev1 parameter sets when needed) and hands the //! other codecs through unchanged. FLV carries a single video and a single audio @@ -20,8 +20,8 @@ use hang::catalog::{AV1, AudioCodec, Catalog, Container, VideoCodec}; use super::{ AAC_AUDIO_TAG_HEADER, AAC_RAW, AAC_SEQUENCE_HEADER, AUDIO_FORMAT_EX, AUDIO_PACKET_CODED_FRAMES, - AUDIO_PACKET_SEQUENCE_START, AVC_NALU, AVC_SEQUENCE_HEADER, FRAME_TYPE_INTER, FRAME_TYPE_KEY, TAG_AUDIO, - TAG_HEADER_LEN, TAG_VIDEO, VIDEO_CODEC_AVC, VIDEO_EX_HEADER, VIDEO_PACKET_CODED_FRAMES, + AUDIO_PACKET_SEQUENCE_START, AVC_NALU, AVC_SEQUENCE_HEADER, FRAME_TYPE_INTER, FRAME_TYPE_KEY, MP3_AUDIO_TAG_HEADER, + TAG_AUDIO, TAG_HEADER_LEN, TAG_VIDEO, VIDEO_CODEC_AVC, VIDEO_EX_HEADER, VIDEO_PACKET_CODED_FRAMES, VIDEO_PACKET_SEQUENCE_START, }; use crate::catalog::{CatalogFormat, Stream}; @@ -36,6 +36,7 @@ enum Flavor { Av1, Vp9, Aac, + Mp3, Opus, Ac3, Eac3, @@ -43,7 +44,7 @@ enum Flavor { impl Flavor { /// The enhanced-RTMP FourCC for this codec, or `None` for the legacy - /// (CodecID-signaled) AVC and AAC shapes. + /// (CodecID-signaled) AVC, AAC, and MP3 shapes. fn fourcc(self) -> Option<[u8; 4]> { match self { Flavor::Hevc => Some(*b"hvc1"), @@ -52,7 +53,7 @@ impl Flavor { Flavor::Opus => Some(*b"Opus"), Flavor::Ac3 => Some(*b"ac-3"), Flavor::Eac3 => Some(*b"ec-3"), - Flavor::Avc | Flavor::Aac => None, + Flavor::Avc | Flavor::Aac | Flavor::Mp3 => None, } } } @@ -387,12 +388,17 @@ impl Export { } else { let flavor = self.audio.as_ref().expect("audio frame without an audio track").flavor; let mut body = BytesMut::with_capacity(5 + frame.payload.len()); - match flavor.fourcc() { - None => { + match flavor { + // Legacy AAC: tag header + AACPacketType (raw frame follows). + Flavor::Aac => { body.put_u8(AAC_AUDIO_TAG_HEADER); body.put_u8(AAC_RAW); } - Some(fourcc) => { + // Legacy MP3: tag header only; the raw frame carries its own config. + Flavor::Mp3 => body.put_u8(MP3_AUDIO_TAG_HEADER), + // Enhanced FourCC CodedFrames. + _ => { + let fourcc = flavor.fourcc().expect("enhanced audio flavor missing FourCC"); body.put_u8((AUDIO_FORMAT_EX << 4) | AUDIO_PACKET_CODED_FRAMES); body.put_slice(&fourcc); } @@ -445,6 +451,7 @@ fn video_flavor(config: &hang::catalog::VideoConfig) -> anyhow::Result { fn audio_flavor(config: &hang::catalog::AudioConfig) -> anyhow::Result { match &config.codec { AudioCodec::AAC(_) => Ok(Flavor::Aac), + AudioCodec::Mp3 => Ok(Flavor::Mp3), AudioCodec::Opus => Ok(Flavor::Opus), AudioCodec::Ac3 => Ok(Flavor::Ac3), AudioCodec::Ec3 => Ok(Flavor::Eac3), @@ -481,13 +488,15 @@ fn video_sequence_header(track: &FlvTrack) -> anyhow::Result> { } // VP9 configures the decoder from the key frame; no sequence header tag. Flavor::Vp9 => return Ok(None), - Flavor::Aac | Flavor::Opus | Flavor::Ac3 | Flavor::Eac3 => unreachable!("audio flavor on a video track"), + Flavor::Aac | Flavor::Mp3 | Flavor::Opus | Flavor::Ac3 | Flavor::Eac3 => { + unreachable!("audio flavor on a video track") + } } Ok(Some(body)) } /// Build the FLV audio sequence-header tag body, or `None` for codecs that carry -/// their config in band (AC-3 / E-AC-3). +/// their config in band (MP3 / AC-3 / E-AC-3). fn audio_sequence_header(track: &FlvTrack) -> anyhow::Result> { let mut body = BytesMut::new(); match track.flavor { @@ -506,8 +515,8 @@ fn audio_sequence_header(track: &FlvTrack) -> anyhow::Result> { body.put_slice(b"Opus"); body.put_slice(head); } - // AC-3 / E-AC-3 carry their config in each sync frame; no sequence header tag. - Flavor::Ac3 | Flavor::Eac3 => return Ok(None), + // MP3 / AC-3 / E-AC-3 carry their config in each frame; no sequence header tag. + Flavor::Mp3 | Flavor::Ac3 | Flavor::Eac3 => return Ok(None), Flavor::Avc | Flavor::Hevc | Flavor::Av1 | Flavor::Vp9 => unreachable!("video flavor on an audio track"), } Ok(Some(body)) diff --git a/rs/moq-mux/src/container/flv/export_test.rs b/rs/moq-mux/src/container/flv/export_test.rs index 160d56d7c1..98eed6e796 100644 --- a/rs/moq-mux/src/container/flv/export_test.rs +++ b/rs/moq-mux/src/container/flv/export_test.rs @@ -276,6 +276,57 @@ async fn export_roundtrips_enhanced() { )); } +/// Legacy MP3 audio survives an import -> export -> import round trip, muxed back +/// out as the legacy SoundFormat 2 tag with the config still in band. +#[tokio::test(start_paused = true)] +async fn export_roundtrips_mp3() { + // MPEG-1 Layer III, 44.1 kHz, joint stereo. + let mut mp3 = vec![0xFF, 0xFB, 0x90, 0x44]; + mp3.resize(417, 0xAA); + + let mut flv = Vec::new(); + flv.extend_from_slice(b"FLV"); + flv.push(1); + flv.push(0x04); // audio only + flv.extend_from_slice(&9u32.to_be_bytes()); + flv.extend_from_slice(&0u32.to_be_bytes()); + let mut tag = vec![super::MP3_AUDIO_TAG_HEADER]; + tag.extend_from_slice(&mp3); + write_tag(&mut flv, super::TAG_AUDIO, 0, &tag); + + let mut producer = moq_net::BroadcastInfo::new().produce(); + let consumer = producer.consume(); + let mut catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + + let mut importer = Import::new(producer, catalog.clone()); + importer.decode(&bytes::BytesMut::from(flv.as_slice())).unwrap(); + catalog.finish().unwrap(); + + let exporter = Export::new(consumer).await.unwrap(); + let exported = drain_export(exporter, importer).await; + + // The audio is muxed as a legacy SoundFormat 2 (MP3) tag, no sequence header. + let tags = parse_tags(&exported); + assert!( + tags.iter() + .any(|t| t.tag_type == super::TAG_AUDIO && (t.body[0] >> 4) == super::AUDIO_FORMAT_MP3), + "expected a legacy MP3 audio tag" + ); + + // Re-import and confirm the codec rebuilds. + let mut bcast2 = moq_net::BroadcastInfo::new().produce(); + let cat2 = crate::catalog::Producer::new(&mut bcast2).unwrap(); + let mut imp2 = Import::new(bcast2, cat2.clone()); + imp2.decode(&bytes::BytesMut::from(exported.as_slice())).unwrap(); + imp2.finish().unwrap(); + + let snap = cat2.snapshot(); + let a = snap.audio.renditions.values().next().unwrap(); + assert!(matches!(a.codec, AudioCodec::Mp3)); + assert_eq!(a.sample_rate, 44100); + assert_eq!(a.channel_count, 2); +} + struct ParsedTag { tag_type: u8, timestamp: u32, diff --git a/rs/moq-mux/src/container/flv/import.rs b/rs/moq-mux/src/container/flv/import.rs index 3e984ec1be..1802f6cf5b 100644 --- a/rs/moq-mux/src/container/flv/import.rs +++ b/rs/moq-mux/src/container/flv/import.rs @@ -9,20 +9,22 @@ //! - **Enhanced RTMP (E-RTMP)**: the FourCC-signaled payloads OBS and ffmpeg emit //! for HEVC (`hvc1`), AV1 (`av01`), VP9 (`vp09`), and the legacy AVC FourCC //! (`avc1`); plus enhanced audio for Opus (`Opus`), AC-3 (`ac-3`), E-AC-3 -//! (`ec-3`), and AAC (`mp4a`). +//! (`ec-3`), AAC (`mp4a`), and MP3 (`.mp3`). +//! +//! MP3 is also accepted as the legacy SoundFormat 2 audio tag. //! //! Each codec's out-of-band config record (avcC / hvcC / av1C / `AudioSpecificConfig` //! / `OpusHead`) becomes the catalog `description`; VP9 and the verbatim audio -//! codecs (AC-3 / E-AC-3) carry their config in band, so they configure from the -//! first frame instead. Sample bytes already match the [`Legacy`](crate::catalog::hang::Container) -//! container, so no codec transform is needed. FLAC (`fLaC`) and MP3 (`.mp3`) -//! enhanced audio, and any other codec, are logged and dropped. +//! codecs (MP3 / AC-3 / E-AC-3) carry their config in band, so they configure from +//! the first frame instead. Sample bytes already match the [`Legacy`](crate::catalog::hang::Container) +//! container, so no codec transform is needed. FLAC (`fLaC`) enhanced audio, and +//! any other codec, are logged and dropped. use bytes::{Buf, Bytes, BytesMut}; use hang::catalog::{AAC, AudioCodec, AudioConfig, Container, H264, VideoConfig}; use super::{ - AAC_RAW, AAC_SEQUENCE_HEADER, AUDIO_FORMAT_AAC, AUDIO_FORMAT_EX, AUDIO_PACKET_CODED_FRAMES, + AAC_RAW, AAC_SEQUENCE_HEADER, AUDIO_FORMAT_AAC, AUDIO_FORMAT_EX, AUDIO_FORMAT_MP3, AUDIO_PACKET_CODED_FRAMES, AUDIO_PACKET_MULTICHANNEL_CONFIG, AUDIO_PACKET_SEQUENCE_END, AUDIO_PACKET_SEQUENCE_START, AVC_NALU, AVC_SEQUENCE_HEADER, FILE_HEADER_LEN, FRAME_TYPE_KEY, PREV_TAG_SIZE_LEN, TAG_AUDIO, TAG_HEADER_LEN, TAG_SCRIPT, TAG_VIDEO, VIDEO_CODEC_AVC, VIDEO_EX_HEADER, VIDEO_PACKET_CODED_FRAMES, VIDEO_PACKET_CODED_FRAMES_X, @@ -235,6 +237,15 @@ impl Import { if sound_format == AUDIO_FORMAT_EX { return self.handle_audio_enhanced(first, body, timestamp); } + if sound_format == AUDIO_FORMAT_MP3 { + // Legacy MP3: the raw frame follows the one-byte tag header, with the + // config in band. Configure from the first frame, then write it. + let frame = &body[1..]; + if self.audio.is_none() { + self.init_audio(config_from_mp3(frame)?)?; + } + return self.write_audio(frame, timestamp); + } if sound_format != AUDIO_FORMAT_AAC { tracing::warn!(sound_format, "unsupported FLV audio format, dropping"); return Ok(()); @@ -263,7 +274,7 @@ impl Import { let config = match &fourcc { b"Opus" => config_from_opus_head(payload)?, b"mp4a" => config_from_asc(payload)?, - // AC-3 / E-AC-3 are verbatim with no sequence header; they + // MP3 / AC-3 / E-AC-3 are verbatim with no sequence header; they // configure from the first frame. Anything else is unsupported. other => { tracing::warn!(fourcc = ?other, "unsupported enhanced FLV audio codec, dropping"); @@ -273,10 +284,11 @@ impl Import { self.init_audio(config) } AUDIO_PACKET_CODED_FRAMES => { - // AC-3 / E-AC-3 carry their config in the frame header, so configure - // from the first frame when no sequence header preceded it. + // MP3 / AC-3 / E-AC-3 carry their config in the frame header, so + // configure from the first frame when no sequence header preceded it. if self.audio.is_none() { let config = match &fourcc { + b".mp3" => Some(config_from_mp3(payload)?), b"ac-3" => Some(config_from_ac3(payload)?), b"ec-3" => Some(config_from_eac3(payload)?), _ => None, @@ -469,6 +481,14 @@ fn config_from_opus_head(head: &[u8]) -> anyhow::Result { Ok(config) } +/// Build an audio config for MP3 from a frame header (config is in band). +fn config_from_mp3(frame: &[u8]) -> anyhow::Result { + let cfg = crate::codec::mp3::Config::parse(frame)?; + let mut config = AudioConfig::new(AudioCodec::Mp3, cfg.sample_rate, cfg.channel_count); + config.container = Container::Legacy; + Ok(config) +} + /// Build an audio config for AC-3 from a sync frame header. fn config_from_ac3(frame: &[u8]) -> anyhow::Result { let header = crate::codec::ac3::parse_header(frame)?; diff --git a/rs/moq-mux/src/container/flv/import_test.rs b/rs/moq-mux/src/container/flv/import_test.rs index ced7d600f0..56afc7c103 100644 --- a/rs/moq-mux/src/container/flv/import_test.rs +++ b/rs/moq-mux/src/container/flv/import_test.rs @@ -223,6 +223,41 @@ async fn import_enhanced_opus() { assert_eq!(a.description.as_ref().map(|b| b.as_ref()), Some(head.as_ref())); } +/// Legacy SoundFormat 2 MP3 configures from the first frame's in-band header and +/// carries the frame through verbatim. +#[tokio::test(start_paused = true)] +async fn import_legacy_mp3() { + // MPEG-1 Layer III, 128 kbps, 44.1 kHz, joint stereo, padded to a plausible frame. + let mut mp3 = vec![0xFF, 0xFB, 0x90, 0x44]; + mp3.resize(417, 0xAA); + + let mut out = Vec::new(); + out.extend_from_slice(b"FLV"); + out.push(1); + out.push(0x04); // audio only + out.extend_from_slice(&9u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + + // Legacy audio tag: SoundFormat 2 (MP3) header byte, then the raw frame. + let mut tag = vec![super::MP3_AUDIO_TAG_HEADER]; + tag.extend_from_slice(&mp3); + write_tag(&mut out, super::TAG_AUDIO, 0, &tag); + + let mut producer = moq_net::BroadcastInfo::new().produce(); + let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + let mut importer = Import::new(producer, catalog.clone()); + importer.decode(&bytes::BytesMut::from(out.as_slice())).unwrap(); + importer.finish().unwrap(); + + let snap = catalog.snapshot(); + assert_eq!(snap.audio.renditions.len(), 1); + let a = snap.audio.renditions.values().next().unwrap(); + assert!(matches!(a.codec, AudioCodec::Mp3)); + assert_eq!(a.sample_rate, 44100); + assert_eq!(a.channel_count, 2); + assert!(a.description.is_none(), "MP3 config is in band"); +} + #[tokio::test(start_paused = true)] async fn import_rejects_non_flv() { let mut producer = moq_net::BroadcastInfo::new().produce(); diff --git a/rs/moq-mux/src/container/flv/mod.rs b/rs/moq-mux/src/container/flv/mod.rs index c911e2e766..d2b20b82c9 100644 --- a/rs/moq-mux/src/container/flv/mod.rs +++ b/rs/moq-mux/src/container/flv/mod.rs @@ -10,12 +10,15 @@ //! - **Enhanced RTMP (E-RTMP)**: the FourCC payloads for HEVC (`hvc1`), AV1 //! (`av01`), VP9 (`vp09`), Opus (`Opus`), AC-3 (`ac-3`), and E-AC-3 (`ec-3`). //! +//! MP3 is handled in both generations: the legacy SoundFormat 2 tag and the +//! E-RTMP `.mp3` FourCC on import, and the legacy SoundFormat 2 tag on export. +//! //! Each codec's config record passes straight through as the catalog -//! `description` (or, for the in-band codecs VP9 / AC-3 / E-AC-3, is read from the -//! frame), and the sample bytes already match the +//! `description` (or, for the in-band codecs VP9 / MP3 / AC-3 / E-AC-3, is read +//! from the frame), and the sample bytes already match the //! [`Legacy`](crate::catalog::hang::Container) container, so no codec transform is -//! needed. Other legacy codecs (VP6, MP3, Speex, …) and the E-RTMP FLAC / MP3 -//! audio are logged and dropped on import, and rejected on export. +//! needed. Other legacy codecs (VP6, Speex, …) and the E-RTMP FLAC audio are +//! logged and dropped on import, and rejected on export. mod export; mod import; @@ -46,6 +49,8 @@ const FILE_HEADER_LEN: usize = 9; const VIDEO_CODEC_AVC: u8 = 7; /// SoundFormat for AAC (high nibble of an audio tag's first byte). const AUDIO_FORMAT_AAC: u8 = 10; +/// SoundFormat for MP3 (high nibble of an audio tag's first byte). +const AUDIO_FORMAT_MP3: u8 = 2; /// Video FrameType for a keyframe. const FRAME_TYPE_KEY: u8 = 1; @@ -67,6 +72,11 @@ const AAC_RAW: u8 = 1; /// channel layout live in the `AudioSpecificConfig`, so these bits are nominal. const AAC_AUDIO_TAG_HEADER: u8 = (AUDIO_FORMAT_AAC << 4) | (3 << 2) | (1 << 1) | 1; +/// First byte of an MP3 audio tag: SoundFormat 2, with the same nominal +/// SoundRate / SoundSize / SoundType bits as AAC. The real rate and channel +/// layout live in the MP3 frame header that follows, so these bits are nominal. +const MP3_AUDIO_TAG_HEADER: u8 = (AUDIO_FORMAT_MP3 << 4) | (3 << 2) | (1 << 1) | 1; + /// Enhanced-RTMP (E-RTMP) video signaling: a set high bit on a video tag's /// first byte switches from a legacy CodecID to a FourCC codec + packet type. const VIDEO_EX_HEADER: u8 = 0x80; diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index ce4981ace0..43a3d78a1b 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -33,7 +33,7 @@ use moq_net::Timestamp; /// a media fragment begins at a sync sample, and its presentation duration. A /// segmenting consumer (e.g. an HLS/LL-HLS packager) needs that to map fragments /// onto segments and parts; narrow the catalog to a single rendition with -/// [`catalog::Filter`](crate::catalog::Filter) so the fragments belong to one track. +/// [`Stream::select`](crate::catalog::Stream::select) so the fragments belong to one track. pub struct Export { broadcast: moq_net::BroadcastConsumer, catalog: Option, @@ -104,9 +104,8 @@ impl Export { /// (un)subscription from `catalog`. /// /// `catalog` is any [`Stream`] of catalog snapshots, typically a - /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or wrapped in - /// [`catalog::Filter`](crate::catalog::Filter) / - /// [`catalog::Target`](crate::catalog::Target) to narrow the rendition set. + /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or narrowed to + /// one rendition set via [`Stream::select`](crate::catalog::Stream::select). pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { Self { broadcast, @@ -317,6 +316,12 @@ impl Export { } let source = ExportSource::for_video(&self.broadcast, name, config, self.latency)?; let timescale = catalog_timescale_video(config); + // A zero / NaN / infinite framerate would make `1.0 / fps` non-finite and panic + // `Duration::from_secs_f64`; fall back to the default in that case. + let framerate = config + .framerate + .filter(|fps| fps.is_finite() && *fps > 0.0) + .unwrap_or(30.0); self.tracks.insert( name.clone(), Fmp4Track { @@ -325,7 +330,7 @@ impl Export { buffer: Vec::new(), buffer_independent: false, is_video: true, - default_frame: Duration::from_secs_f64(1.0 / config.framerate.unwrap_or(30.0)), + default_frame: Duration::from_secs_f64(1.0 / framerate), finished: false, track_id: next_track_id, timescale, diff --git a/rs/moq-mux/src/container/fmp4/export_test.rs b/rs/moq-mux/src/container/fmp4/export_test.rs index 7a10b213e9..f0f5e3d948 100644 --- a/rs/moq-mux/src/container/fmp4/export_test.rs +++ b/rs/moq-mux/src/container/fmp4/export_test.rs @@ -637,3 +637,46 @@ async fn next_fragment_reports_segment_metadata() { drop(catalog); drop(producer); } + +/// A legacy FLAC rendition (no init segment) synthesizes a `fLaC` sample entry +/// whose `dfLa` STREAMINFO is rebuilt from the catalog description. +#[test] +fn synthesize_flac_trak() { + let description = crate::codec::flac::Config { + min_block_size: 4096, + max_block_size: 4096, + min_frame_size: 0, + max_frame_size: 0, + sample_rate: 96_000, + channel_count: 2, + bits_per_sample: 24, + total_samples: 0, + md5: [0; 16], + } + .description(); + + let mut config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Flac, 96_000, 2); + config.description = Some(description); + + let trak = super::synthesize_audio_trak(1, 96_000, &config).expect("synthesize FLAC trak"); + let codec = &trak.mdia.minf.stbl.stsd.codecs[0]; + let mp4_atom::Codec::Flac(flac) = codec else { + panic!("expected a FLAC sample entry, got {codec:?}"); + }; + + let stream_info = flac + .dfla + .blocks + .iter() + .find_map(|b| match b { + mp4_atom::FlacMetadataBlock::StreamInfo { + sample_rate, + num_channels_minus_one, + .. + } => Some((*sample_rate, *num_channels_minus_one)), + _ => None, + }) + .expect("STREAMINFO block"); + // STREAMINFO carries the real 96 kHz rate even though the 16.16 audio box can't. + assert_eq!(stream_info, (96_000, 1)); +} diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 763d67403e..5de4f2e206 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -2,7 +2,7 @@ use bytes::{Bytes, BytesMut}; use hang::catalog::{AAC, AudioCodec, AudioConfig, Container, H264, H265, VP9, VideoCodec, VideoConfig}; use moq_net::Timestamp; use mp4_atom::{Any, Atom, DecodeMaybe, Encode, Mdat, Moof, Moov, Trak}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use super::Error; use crate::Result; @@ -24,6 +24,7 @@ use crate::Result; /// **Audio:** /// - AAC (MP4A) /// - Opus +/// - FLAC pub struct Import { /// The broadcast being produced broadcast: moq_net::BroadcastProducer, @@ -31,9 +32,16 @@ pub struct Import { /// The catalog being produced catalog: crate::catalog::Producer, + // Which track roles to publish. `None` imports every supported track. + select: Option, + // A lookup to tracks in the broadcast tracks: HashMap, + // Track ids skipped by `select`, so their moof fragments are ignored rather + // than treated as referencing an unknown track. + skipped: HashSet, + // The moov atom at the start of the file. moov: Option, @@ -78,7 +86,9 @@ impl Import { pub fn new(broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer) -> Self { Self { catalog, + select: None, tracks: HashMap::default(), + skipped: HashSet::default(), moov: None, moof: None, moof_size: 0, @@ -87,6 +97,27 @@ impl Import { } } + /// Restrict which track roles are published. + /// + /// fMP4 import selects whole roles: a [`select::Broadcast`](crate::select::Broadcast) + /// that doesn't select video drops every video track, and likewise for audio. The + /// rendition-level narrowing inside a [`select::Video`](crate::select::Video) / + /// [`select::Audio`](crate::select::Audio) (by name or codec) is a consume-side + /// concern and is ignored here. Without this, every supported track is imported. + pub fn with_select(mut self, select: crate::select::Broadcast) -> Self { + self.select = Some(select); + self + } + + /// Whether `kind` is selected for import (every role when unset). + fn selects(&self, kind: &TrackKind) -> bool { + match (&self.select, kind) { + (None, _) => true, + (Some(select), TrackKind::Video) => select.has_video(), + (Some(select), TrackKind::Audio) => select.has_audio(), + } + } + /// Decode a buffer of bytes. pub fn decode(&mut self, data: &[u8]) -> Result<()> { self.buffer.extend_from_slice(data); @@ -156,6 +187,24 @@ impl Import { let handler = &trak.mdia.hdlr.handler; let suffix = ".m4s"; + let kind = match handler.as_ref() { + b"vide" => TrackKind::Video, + b"soun" => TrackKind::Audio, + b"sbtl" => return Err(Error::UnsupportedSubtitle.into()), + handler => { + let mut buf = [0u8; 4]; + buf[..handler.len().min(4)].copy_from_slice(&handler[..handler.len().min(4)]); + return Err(Error::UnknownTrackHandler(buf).into()); + } + }; + + // Drop tracks whose role isn't selected before minting or publishing them; their + // moof fragments are ignored in `extract`. + if !self.selects(&kind) { + self.skipped.insert(track_id); + continue; + } + // Declare the track at the fMP4's native timescale. Frame timestamps are // emitted at this same scale (see below), so they satisfy the track's // timescale invariant and ride the wire for the relay, redundant with the @@ -166,24 +215,16 @@ impl Import { moq_net::TrackInfo::default().with_timescale(timescale), )?; - let kind = match handler.as_ref() { - b"vide" => { + match kind { + TrackKind::Video => { let config = self.init_video(trak, &moov)?; catalog.video.renditions.insert(track.name().to_string(), config); - TrackKind::Video } - b"soun" => { + TrackKind::Audio => { let config = self.init_audio(trak, &moov)?; catalog.audio.renditions.insert(track.name().to_string(), config); - TrackKind::Audio } - b"sbtl" => return Err(Error::UnsupportedSubtitle.into()), - handler => { - let mut buf = [0u8; 4]; - buf[..handler.len().min(4)].copy_from_slice(&handler[..handler.len().min(4)]); - return Err(Error::UnknownTrackHandler(buf).into()); - } - }; + } self.tracks.insert( track_id, @@ -393,6 +434,46 @@ impl Import { config.container = container; config } + mp4_atom::Codec::Flac(flac) => { + // Rate/channels come from STREAMINFO, not the sample entry's `Audio` + // box: the latter's sample rate is 16.16 fixed point and can't carry + // FLAC's high rates (96k/192k). STREAMINFO is also where the decoder + // description comes from. + let info = flac + .dfla + .blocks + .iter() + .find_map(|block| match block { + mp4_atom::FlacMetadataBlock::StreamInfo { + minimum_block_size, + maximum_block_size, + minimum_frame_size, + maximum_frame_size, + sample_rate, + num_channels_minus_one, + bits_per_sample_minus_one, + number_of_interchannel_samples, + md5_checksum, + } => Some(crate::codec::flac::Config { + min_block_size: *minimum_block_size, + max_block_size: *maximum_block_size, + min_frame_size: u32::from(*minimum_frame_size), + max_frame_size: u32::from(*maximum_frame_size), + sample_rate: *sample_rate, + channel_count: *num_channels_minus_one as u32 + 1, + bits_per_sample: *bits_per_sample_minus_one as u32 + 1, + total_samples: *number_of_interchannel_samples, + md5: md5_checksum.as_slice().try_into().unwrap_or([0; 16]), + }), + _ => None, + }) + .ok_or(crate::codec::flac::Error::MissingStreamInfo)?; + + let mut config = AudioConfig::new(AudioCodec::Flac, info.sample_rate, info.channel_count); + config.description = Some(info.description()); + config.container = container; + config + } mp4_atom::Codec::Unknown(unknown) => return Err(Error::UnknownCodec(*unknown).into()), unsupported => return Err(Error::UnsupportedCodec(Box::new(unsupported.clone())).into()), }; @@ -410,7 +491,12 @@ impl Import { // Loop over all of the traf boxes in the moof. for traf in &moof.traf { let track_id = traf.tfhd.track_id; - let track = self.tracks.get_mut(&track_id).ok_or(Error::UnknownTrack(track_id))?; + let track = match self.tracks.get_mut(&track_id) { + Some(track) => track, + // A fragment for a track `select` dropped: ignore it. + None if self.skipped.contains(&track_id) => continue, + None => return Err(Error::UnknownTrack(track_id).into()), + }; // Find the track information in the moov let trak = moov @@ -487,12 +573,16 @@ impl Import { return Err(Error::MissingSampleDuration.into()); } - let pts = (dts as i64 + entry.cts.unwrap_or_default() as i64) as u64; + // Checked: a negative composition offset must not wrap into a huge u64 PTS. + let pts = dts + .checked_add_signed(entry.cts.unwrap_or_default() as i64) + .ok_or(Error::PtsOverflow)?; // Preserve the fmp4 track's native timescale so a passthrough re-emit // doesn't go through a lossy microsecond detour. let timestamp = moq_net::Timestamp::new(pts, timescale)?; - if offset + size > mdat.data.len() { + let sample_end = offset.checked_add(size).ok_or(Error::InvalidDataOffset)?; + if sample_end > mdat.data.len() { return Err(Error::InvalidDataOffset.into()); } @@ -524,9 +614,9 @@ impl Import { track.last_timestamp = Some(timestamp); if let Some(duration) = duration { - dts += duration as u64; + dts = dts.checked_add(duration as u64).ok_or(Error::PtsOverflow)?; } - offset += size; + offset = sample_end; sample_index += 1; } } diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index bfef597a64..5f046d1d78 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -26,6 +26,26 @@ fn run_fmp4(data: &[u8]) -> crate::catalog::hang::Catalog { catalog.snapshot() } +fn run_fmp4_select(data: &[u8], select: crate::select::Broadcast) -> crate::catalog::hang::Catalog { + let mut broadcast = moq_net::BroadcastInfo::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + + let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.clone()).with_select(select); + + // A dropped track's moof fragments must be skipped, not raise `UnknownTrack`. + // (The test files end on a malformed fragment, so other decode errors are expected + // and ignored; only `UnknownTrack` would mean the skip path regressed.) + let buf = bytes::BytesMut::from(data); + if let Err(err) = fmp4.decode(&buf) { + assert!( + !matches!(err, crate::Error::Cmaf(crate::container::fmp4::Error::UnknownTrack(_))), + "a skipped track's fragment raised UnknownTrack: {err:?}" + ); + } + + catalog.snapshot() +} + fn decode_init(init: &[u8]) -> (mp4_atom::Ftyp, mp4_atom::Moov) { let mut cursor = std::io::Cursor::new(init); let ftyp = mp4_atom::Ftyp::decode(&mut cursor).expect("invalid ftyp"); @@ -54,6 +74,38 @@ fn test_bbb_catalog() { assert!(matches!(audio.container, Container::Cmaf { .. })); } +#[test] +fn select_video_only() { + use crate::select::{Broadcast, Video}; + + let data = include_bytes!("test_data/bbb.mp4"); + let catalog = run_fmp4_select(data, Broadcast::default().video(Video::default())); + + // The muxed audio track is dropped; only video is published. + assert_eq!(catalog.video.renditions.len(), 1); + assert!(catalog.audio.renditions.is_empty()); +} + +#[test] +fn select_audio_only() { + use crate::select::{Audio, Broadcast}; + + let data = include_bytes!("test_data/bbb.mp4"); + let catalog = run_fmp4_select(data, Broadcast::default().audio(Audio::default())); + + assert!(catalog.video.renditions.is_empty()); + assert_eq!(catalog.audio.renditions.len(), 1); +} + +#[test] +fn select_nothing_publishes_nothing() { + let data = include_bytes!("test_data/bbb.mp4"); + let catalog = run_fmp4_select(data, crate::select::Broadcast::default()); + + assert!(catalog.video.renditions.is_empty()); + assert!(catalog.audio.renditions.is_empty()); +} + #[test] fn test_bbb_init_roundtrip() { let data = include_bytes!("test_data/bbb.mp4"); @@ -299,3 +351,74 @@ fn decode_multisample_with_durations_roundtrips() { assert_eq!(out[0].timestamp.as_micros(), 0); assert_eq!(out[1].timestamp.as_micros(), 33_000); } + +/// A FLAC track (fLaC sample entry + dfLa STREAMINFO) imports into the catalog with +/// rate/channels taken from STREAMINFO (not the 16.16 audio box) and the WebCodecs +/// description carried out of band. +#[test] +fn test_flac_catalog() { + // 96 kHz can't be represented in the sample entry's 16.16 `Audio` rate field, so + // this also proves STREAMINFO is the source of truth. + let stream_info = mp4_atom::FlacMetadataBlock::StreamInfo { + minimum_block_size: 4096, + maximum_block_size: 4096, + minimum_frame_size: 0u32.try_into().unwrap(), + maximum_frame_size: 0u32.try_into().unwrap(), + sample_rate: 96_000, + num_channels_minus_one: 1, + bits_per_sample_minus_one: 23, + number_of_interchannel_samples: 0, + md5_checksum: vec![0; 16], + }; + let flac = mp4_atom::Flac { + audio: mp4_atom::Audio { + data_reference_index: 1, + channel_count: 2, + sample_size: 24, + sample_rate: mp4_atom::FixedPoint::from(0u16), + }, + dfla: mp4_atom::Dfla { + blocks: vec![stream_info], + }, + }; + + let trak = super::build_audio_trak(1, 96_000, mp4_atom::Codec::from(flac)); + let moov = mp4_atom::Moov { + mvhd: mp4_atom::Mvhd { + timescale: 1000, + ..Default::default() + }, + trak: vec![trak], + mvex: Some(mp4_atom::Mvex { + mehd: None, + trex: vec![mp4_atom::Trex { + track_id: 1, + default_sample_description_index: 1, + ..Default::default() + }], + }), + ..Default::default() + }; + let ftyp = mp4_atom::Ftyp { + major_brand: b"isom".into(), + minor_version: 0x200, + compatible_brands: vec![b"isom".into(), b"iso6".into()], + }; + + let mut data = Vec::new(); + ftyp.encode(&mut data).unwrap(); + moov.encode(&mut data).unwrap(); + + let catalog = run_fmp4(&data); + assert_eq!(catalog.audio.renditions.len(), 1); + + let a = catalog.audio.renditions.values().next().unwrap(); + assert!(matches!(a.codec, hang::catalog::AudioCodec::Flac)); + assert_eq!(a.sample_rate, 96_000); + assert_eq!(a.channel_count, 2); + // fmp4 import is CMAF passthrough. + assert!(matches!(a.container, Container::Cmaf { .. })); + // The WebCodecs FLAC description: `fLaC` marker + STREAMINFO. + let desc = a.description.as_ref().expect("flac description"); + assert_eq!(&desc[..4], b"fLaC"); +} diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index f1e358cf47..166a6483f1 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -36,6 +36,9 @@ pub enum Error { #[error("moq: {0}")] Moq(#[from] moq_net::Error), + #[error("flac: {0}")] + Flac(#[from] crate::codec::flac::Error), + #[error("missing keyframe: a group must open on a keyframe")] MissingKeyframe(#[from] crate::container::MissingKeyframe), @@ -525,6 +528,35 @@ pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &Audi taic: None, }) } + AudioCodec::Flac => { + // The catalog `description` is the FLAC header (`fLaC` marker + STREAMINFO). + // Parse it back into the STREAMINFO fields the `dfLa` box stores. + let description = config + .description + .as_ref() + .ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?; + let info = crate::codec::flac::Config::parse(&mut description.as_ref())?; + + let stream_info = mp4_atom::FlacMetadataBlock::StreamInfo { + minimum_block_size: info.min_block_size, + maximum_block_size: info.max_block_size, + // Frame sizes are 24-bit; clamp defensively so the conversion can't fail. + minimum_frame_size: info.min_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"), + maximum_frame_size: info.max_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"), + sample_rate: info.sample_rate, + num_channels_minus_one: info.channel_count.saturating_sub(1) as u8, + bits_per_sample_minus_one: info.bits_per_sample.saturating_sub(1) as u8, + number_of_interchannel_samples: info.total_samples, + md5_checksum: info.md5.to_vec(), + }; + + mp4_atom::Codec::from(mp4_atom::Flac { + audio, + dfla: mp4_atom::Dfla { + blocks: vec![stream_info], + }, + }) + } other => return Err(Error::UnsupportedSynthesis(format!("audio codec {:?}", other))), }; diff --git a/rs/moq-mux/src/container/mkv/export.rs b/rs/moq-mux/src/container/mkv/export.rs index da28ee9cdb..3626fe7a8a 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -156,8 +156,8 @@ impl Export { /// (un)subscription from `catalog`. /// /// `catalog` is any [`Stream`] of catalog snapshots, typically a - /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or wrapped in - /// [`catalog::Filter`](crate::catalog::Filter) to narrow the rendition set. + /// [`catalog::Consumer`](crate::catalog::Consumer) directly, or narrowed to + /// one rendition set via [`Stream::select`](crate::catalog::Stream::select). pub fn new(broadcast: moq_net::BroadcastConsumer, catalog: S) -> Self { Self { broadcast, @@ -573,7 +573,7 @@ fn build_video_track_entry( Ok(MatroskaSpec::TrackEntry(Master::Full(entry))) } -fn build_audio_track_entry(track_number: u64, config: &AudioConfig) -> Result { +pub(super) fn build_audio_track_entry(track_number: u64, config: &AudioConfig) -> Result { let (codec_id, codec_private) = match &config.codec { AudioCodec::Opus => ( "A_OPUS", @@ -596,20 +596,36 @@ fn build_audio_track_entry(track_number: u64, config: &AudioConfig) -> Result ( + "A_FLAC", + // A_FLAC CodecPrivate is the FLAC header (the `fLaC` marker plus metadata + // blocks), which is exactly the catalog description. + Some( + config + .description + .as_ref() + .ok_or(Error::MissingFlacDescription)? + .to_vec(), + ), + ), + // MP3 carries its config in band, so there's no codec private. + AudioCodec::Mp3 => ("A_MPEG/L3", None), other => return Err(Error::UnsupportedAudioExport(format!("{:?}", other)).into()), }; - let entry = vec![ + let mut entry = vec![ MatroskaSpec::TrackNumber(track_number), MatroskaSpec::TrackUID(track_number), MatroskaSpec::TrackType(2), MatroskaSpec::CodecID(codec_id.to_string()), - MatroskaSpec::CodecPrivate(codec_private.unwrap()), - MatroskaSpec::Audio(Master::Full(vec![ - MatroskaSpec::SamplingFrequency(config.sample_rate as f64), - MatroskaSpec::Channels(config.channel_count as u64), - ])), ]; + if let Some(cp) = codec_private { + entry.push(MatroskaSpec::CodecPrivate(cp)); + } + entry.push(MatroskaSpec::Audio(Master::Full(vec![ + MatroskaSpec::SamplingFrequency(config.sample_rate as f64), + MatroskaSpec::Channels(config.channel_count as u64), + ]))); Ok(MatroskaSpec::TrackEntry(Master::Full(entry))) } diff --git a/rs/moq-mux/src/container/mkv/export_test.rs b/rs/moq-mux/src/container/mkv/export_test.rs index 5cc8cb87b5..1b8a1dab02 100644 --- a/rs/moq-mux/src/container/mkv/export_test.rs +++ b/rs/moq-mux/src/container/mkv/export_test.rs @@ -139,6 +139,140 @@ async fn export_header_roundtrip_vp9_opus() { assert_eq!(a.sample_rate, 48000); } +/// A FLAC rendition exports as an `A_FLAC` track whose CodecPrivate is the catalog +/// description (the `fLaC` header), which round-trips back through the importer. +#[test] +fn build_flac_audio_track_entry() { + let description = crate::codec::flac::Config { + min_block_size: 4096, + max_block_size: 4096, + min_frame_size: 0, + max_frame_size: 0, + sample_rate: 48_000, + channel_count: 2, + bits_per_sample: 16, + total_samples: 0, + md5: [0; 16], + } + .description(); + + let mut config = hang::catalog::AudioConfig::new(AudioCodec::Flac, 48_000, 2); + config.description = Some(description.clone()); + + let entry = super::export::build_audio_track_entry(2, &config).expect("build A_FLAC entry"); + let MatroskaSpec::TrackEntry(Master::Full(children)) = entry else { + panic!("expected a TrackEntry"); + }; + + let codec_id = children + .iter() + .find_map(|c| { + if let MatroskaSpec::CodecID(s) = c { + Some(s.clone()) + } else { + None + } + }) + .expect("codec id"); + assert_eq!(codec_id, "A_FLAC"); + + let private = children + .iter() + .find_map(|c| { + if let MatroskaSpec::CodecPrivate(p) = c { + Some(p.clone()) + } else { + None + } + }) + .expect("codec private"); + // CodecPrivate is the FLAC header verbatim, ready for the importer to parse. + assert_eq!(private, description.to_vec()); + crate::codec::flac::Config::parse(&mut private.as_slice()).expect("valid FLAC header"); +} + +/// MP3 (config in band, no codec private) survives an import -> export -> re-import +/// round trip as the `A_MPEG/L3` track entry. +#[tokio::test(start_paused = true)] +async fn export_header_roundtrip_mp3() { + let import_bytes = synth_matroska_mp3(); + + let broadcast = moq_net::BroadcastInfo::new(); + let mut producer = broadcast.produce(); + let consumer = producer.consume(); + + let catalog = crate::catalog::Producer::new(&mut producer).unwrap(); + let mut importer = crate::container::mkv::Import::new(producer, catalog.clone()); + importer + .decode(&bytes::BytesMut::from(import_bytes.as_slice())) + .unwrap(); + importer.finish().unwrap(); + + let catalog_stream = crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang) + .await + .expect("catalog consumer"); + let mut exporter = crate::container::mkv::Export::new(consumer, catalog_stream); + + let header = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) + .await + .expect("exporter timed out") + .expect("exporter result") + .expect("expected header bytes"); + + // Re-import the exported header and confirm the codec rebuilds. + let mut broadcast2 = moq_net::BroadcastInfo::new().produce(); + let catalog2 = crate::catalog::Producer::new(&mut broadcast2).unwrap(); + let mut importer2 = crate::container::mkv::Import::new(broadcast2, catalog2.clone()); + importer2.decode(&bytes::BytesMut::from(header.as_ref())).unwrap(); + + let snap = catalog2.snapshot(); + assert_eq!(snap.audio.renditions.len(), 1); + let a = snap.audio.renditions.values().next().unwrap(); + assert!(matches!(a.codec, AudioCodec::Mp3)); + assert_eq!(a.sample_rate, 44100); + assert_eq!(a.channel_count, 2); +} + +/// Build a small Matroska with a single MP3 audio track (no codec private). +fn synth_matroska_mp3() -> Vec { + use webm_iterable::WebmWriter; + + let tags: Vec = vec![ + MatroskaSpec::Ebml(Master::Full(vec![ + MatroskaSpec::DocType("matroska".to_string()), + MatroskaSpec::DocTypeVersion(2), + MatroskaSpec::DocTypeReadVersion(2), + ])), + MatroskaSpec::Segment(Master::Start), + MatroskaSpec::Info(Master::Full(vec![MatroskaSpec::TimestampScale(1_000_000)])), + MatroskaSpec::Tracks(Master::Full(vec![MatroskaSpec::TrackEntry(Master::Full(vec![ + MatroskaSpec::TrackNumber(1), + MatroskaSpec::TrackUID(1), + MatroskaSpec::TrackType(2), + MatroskaSpec::CodecID("A_MPEG/L3".to_string()), + MatroskaSpec::Audio(Master::Full(vec![ + MatroskaSpec::SamplingFrequency(44100.0), + MatroskaSpec::Channels(2), + ])), + ]))])), + MatroskaSpec::Cluster(Master::Start), + MatroskaSpec::Timestamp(0), + SimpleBlock::new_uncheked(b"mp3-frame", 1, 0, false, None, false, true).into(), + MatroskaSpec::Cluster(Master::End), + MatroskaSpec::Segment(Master::End), + ]; + + let mut dest = Cursor::new(Vec::new()); + { + let mut writer = WebmWriter::new(&mut dest); + for tag in &tags { + writer.write(tag).unwrap(); + } + writer.flush().unwrap(); + } + dest.into_inner() +} + /// A mid-stream subscriber may poll the exporter before the catalog track has /// arrived. With `tracks` empty, `header_ready()` must not be vacuously true and /// drive `build_header` into a "no catalog snapshot" error; it should stay diff --git a/rs/moq-mux/src/container/mkv/import.rs b/rs/moq-mux/src/container/mkv/import.rs index bfe40a1c27..e5c7d93dfc 100644 --- a/rs/moq-mux/src/container/mkv/import.rs +++ b/rs/moq-mux/src/container/mkv/import.rs @@ -34,8 +34,10 @@ const DEFAULT_TIMESTAMP_SCALE_NS: u64 = 1_000_000; /// **Audio:** /// - AAC (`A_AAC`) /// - Opus (`A_OPUS`) +/// - FLAC (`A_FLAC`) +/// - MP3 (`A_MPEG/L3`) /// -/// Unsupported codecs (e.g. Vorbis, AC3, MP3, subtitles) are logged and dropped. +/// Unsupported codecs (e.g. Vorbis, AC3, subtitles) are logged and dropped. pub struct Import { broadcast: moq_net::BroadcastProducer, catalog: crate::catalog::Producer, @@ -535,6 +537,42 @@ fn build_audio_config( config.container = Container::Legacy; Ok(config) } + "A_FLAC" => { + // Matroska A_FLAC CodecPrivate is the FLAC header: the `fLaC` marker + // followed by the metadata blocks (STREAMINFO first). That is exactly the + // WebCodecs FLAC description, so it passes straight through, and STREAMINFO + // is authoritative for rate/channels. + let priv_data = codec_private.ok_or(Error::MissingCodecPrivate { + codec_id: "A_FLAC", + purpose: "FLAC STREAMINFO", + })?; + let mut cursor = priv_data.clone(); + let cfg = crate::codec::flac::Config::parse(&mut cursor)?; + + let mut config = AudioConfig::new( + AudioCodec::Flac, + if cfg.sample_rate > 0 { + cfg.sample_rate + } else { + sample_rate + }, + if cfg.channel_count > 0 { + cfg.channel_count + } else { + channels + }, + ); + config.description = Some(priv_data.clone()); + config.container = Container::Legacy; + Ok(config) + } + "A_MPEG/L3" => { + // MP3 carries its config in band, so there's no codec private; the track + // header's SamplingFrequency/Channels are the only config source. + let mut config = AudioConfig::new(AudioCodec::Mp3, sample_rate, channels); + config.container = Container::Legacy; + Ok(config) + } other => Err(Error::UnsupportedAudioCodec(other.to_string()).into()), } } diff --git a/rs/moq-mux/src/container/mkv/import_test.rs b/rs/moq-mux/src/container/mkv/import_test.rs index a2b46e0505..aeaa1d235c 100644 --- a/rs/moq-mux/src/container/mkv/import_test.rs +++ b/rs/moq-mux/src/container/mkv/import_test.rs @@ -121,6 +121,50 @@ fn track_entry_audio_opus(number: u64, sample_rate: f64, channels: u64) -> Matro ])) } +fn track_entry_audio_flac(number: u64, sample_rate: u32, channels: u32) -> MatroskaSpec { + // A_FLAC CodecPrivate is the FLAC header: the `fLaC` marker plus the STREAMINFO + // metadata block. Reuse the codec helper so the bytes match what the importer parses. + let private = crate::codec::flac::Config { + min_block_size: 4096, + max_block_size: 4096, + min_frame_size: 0, + max_frame_size: 0, + sample_rate, + channel_count: channels, + bits_per_sample: 16, + total_samples: 0, + md5: [0; 16], + } + .description() + .to_vec(); + + MatroskaSpec::TrackEntry(Master::Full(vec![ + MatroskaSpec::TrackNumber(number), + MatroskaSpec::TrackUID(number), + MatroskaSpec::TrackType(2), + MatroskaSpec::CodecID("A_FLAC".to_string()), + MatroskaSpec::CodecPrivate(private), + MatroskaSpec::Audio(Master::Full(vec![ + MatroskaSpec::SamplingFrequency(sample_rate as f64), + MatroskaSpec::Channels(channels as u64), + ])), + ])) +} + +fn track_entry_audio_mp3(number: u64, sample_rate: f64, channels: u64) -> MatroskaSpec { + // MP3 has no codec private; config comes from the track header. + MatroskaSpec::TrackEntry(Master::Full(vec![ + MatroskaSpec::TrackNumber(number), + MatroskaSpec::TrackUID(number), + MatroskaSpec::TrackType(2), + MatroskaSpec::CodecID("A_MPEG/L3".to_string()), + MatroskaSpec::Audio(Master::Full(vec![ + MatroskaSpec::SamplingFrequency(sample_rate), + MatroskaSpec::Channels(channels), + ])), + ])) +} + fn track_entry_video_vp9(number: u64, width: u64, height: u64) -> MatroskaSpec { MatroskaSpec::TrackEntry(Master::Full(vec![ MatroskaSpec::TrackNumber(number), @@ -144,6 +188,30 @@ fn run(data: &[u8]) -> crate::catalog::hang::Catalog { catalog.snapshot() } +#[test] +fn test_flac_catalog() { + let data = MkvBuilder::new() + .header("webm") + .segment_start() + .info(1_000_000) + .tracks(vec![track_entry_audio_flac(1, 48000, 2)]) + .cluster(0, || vec![simple_block(1, 0, true, b"flac-frame-0")]) + .segment_end() + .build(); + + let catalog = run(&data); + assert_eq!(catalog.audio.renditions.len(), 1); + + let a = catalog.audio.renditions.values().next().unwrap(); + assert!(matches!(a.codec, AudioCodec::Flac)); + assert_eq!(a.sample_rate, 48000); + assert_eq!(a.channel_count, 2); + assert!(matches!(a.container, Container::Legacy)); + // The description is the WebCodecs FLAC config: the `fLaC` marker + STREAMINFO. + let desc = a.description.as_ref().expect("flac description"); + assert_eq!(&desc[..4], b"fLaC"); +} + #[test] fn test_vp9_only_catalog() { let data = MkvBuilder::new() @@ -200,6 +268,26 @@ fn test_vp9_opus_catalog() { assert_eq!(a.channel_count, 2); } +#[test] +fn test_mp3_catalog() { + let data = MkvBuilder::new() + .header("matroska") + .segment_start() + .info(1_000_000) + .tracks(vec![track_entry_audio_mp3(1, 44100.0, 2)]) + .cluster(0, || vec![simple_block(1, 0, true, b"mp3-frame")]) + .segment_end() + .build(); + + let catalog = run(&data); + assert_eq!(catalog.audio.renditions.len(), 1); + let a = catalog.audio.renditions.values().next().unwrap(); + assert!(matches!(a.codec, AudioCodec::Mp3)); + assert_eq!(a.sample_rate, 44100); + assert_eq!(a.channel_count, 2); + assert!(a.description.is_none(), "MP3 config is in band"); +} + #[test] fn test_chunked_decode_dedup() { // Build the same WebM and feed it in tiny chunks. The dedup logic should ensure diff --git a/rs/moq-mux/src/container/mkv/mod.rs b/rs/moq-mux/src/container/mkv/mod.rs index 5cd6b0a22d..3af5028c5e 100644 --- a/rs/moq-mux/src/container/mkv/mod.rs +++ b/rs/moq-mux/src/container/mkv/mod.rs @@ -82,6 +82,9 @@ pub enum Error { #[error("AAC track missing AudioSpecificConfig (description)")] MissingAacDescription, + #[error("FLAC track missing STREAMINFO (description)")] + MissingFlacDescription, + #[error("H.264 track missing AVCDecoderConfigurationRecord")] MissingH264Avcc, diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index 08119c3bcb..57cf68e014 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -109,6 +109,10 @@ enum Kind { sample_rate: u32, channel_count: u32, }, + /// Opus (private stream_type 0x06). Each frame is one Opus packet, prefixed with + /// the Opus-in-TS access-unit control header and announced with the 'Opus' + /// registration plus DVB extension descriptor. + Opus { channel_count: u32 }, /// MP2, carried verbatim. The sample rate picks the stream type on the way /// out (0x03 vs 0x04). Mp2 { sample_rate: u32 }, @@ -533,9 +537,12 @@ impl Export { ); let pcr_pid = video .or_else(|| { - tracks - .iter() - .find(|t| matches!(t.kind, Kind::Aac { .. } | Kind::Mp2 { .. } | Kind::Ac3 | Kind::Eac3)) + tracks.iter().find(|t| { + matches!( + t.kind, + Kind::Aac { .. } | Kind::Opus { .. } | Kind::Mp2 { .. } | Kind::Ac3 | Kind::Eac3 + ) + }) }) .map(|t| t.pid) .context("TS export requires a video or audio track for the PCR")?; @@ -546,6 +553,9 @@ impl Export { let stream_type = match &t.kind { Kind::Video(stream_type) => *stream_type, Kind::Aac { .. } => StreamType::AdtsAac, + // Opus rides private-data PES; the registration + extension descriptors + // below tell the demuxer it's Opus. + Kind::Opus { .. } => StreamType::from_u8(0x06).map_err(anyhow::Error::msg)?, // 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. @@ -572,6 +582,7 @@ impl Export { tag: 0x05, data: b"EAC3".to_vec(), }], + Kind::Opus { channel_count } => opus_descriptors(*channel_count), _ => Vec::new(), } }; @@ -665,6 +676,8 @@ impl Export { framed.extend_from_slice(&frame.payload); Some(framed) } + // Each moq Opus frame is one packet; prefix the Opus-in-TS control header. + Kind::Opus { .. } => Some(opus_es_payload(&frame.payload)), // Legacy audio frames were ingested whole (framing header included), so // they pass through untouched. PES-framed verbatim payloads likewise. Kind::Mp2 { .. } | Kind::Ac3 | Kind::Eac3 => Some(frame.payload.to_vec()), @@ -714,6 +727,8 @@ impl Export { // derives it from is_video. let stream_id = match &kind { Kind::Verbatim { stream_id, .. } => Some(stream_id.unwrap_or(StreamId::PRIVATE_STREAM_1)), + // Opus is private-data PES, carried under private_stream_1 like ffmpeg. + Kind::Opus { .. } => Some(StreamId::PRIVATE_STREAM_1), _ => None, }; let unit = PesUnit { @@ -976,12 +991,56 @@ fn audio_kind(config: &AudioConfig, name: &str) -> anyhow::Result { AudioCodec::Mp2 => Ok(Kind::Mp2 { sample_rate: config.sample_rate, }), + AudioCodec::Opus => Ok(Kind::Opus { + channel_count: config.channel_count, + }), AudioCodec::Ac3 => Ok(Kind::Ac3), AudioCodec::Ec3 => Ok(Kind::Eac3), other => anyhow::bail!("TS export does not support audio codec {other:?} (track '{name}')"), } } +/// The two PMT descriptors for an Opus elementary stream: the `Opus` registration +/// descriptor (which sets the codec) and the DVB extension descriptor 0x80 carrying +/// the channel configuration. ffmpeg's demuxer requires both to recognize the stream. +fn opus_descriptors(channel_count: u32) -> Vec { + vec![ + Descriptor { + tag: 0x05, + data: b"Opus".to_vec(), + }, + Descriptor { + tag: 0x7f, + // extension_descriptor_tag 0x80, then channel_config_code (1=mono, 2=stereo, + // = channel count for the Vorbis mapping), clamped to the 1..=8 the demuxer reads. + data: vec![0x80, channel_count.clamp(1, 8) as u8], + }, + ] +} + +/// Wrap a raw Opus packet in the Opus-in-TS access-unit control header, producing one +/// PES access unit. Emits the 11-bit `0x3FF` sync (no trim, no control extension), then +/// the `0xFF`-run `au_size`, then the packet. +fn opus_es_payload(packet: &[u8]) -> Vec { + let mut out = Vec::with_capacity(packet.len() + 4); + // Sync 0x3FF over 11 bits: all of byte 0 (0x7F) plus the top 3 bits of byte 1. The + // low 5 bits of byte 1 are the start-trim/end-trim/control-extension flags, all clear. + out.push(0x7f); + out.push(0xe0); + // au_size: a run of 0xFF bytes summing toward the size, then a final byte < 0xFF. A + // size that is an exact multiple of 255 still emits a terminating 0x00 byte. + let mut n = packet.len(); + loop { + out.push(n.min(255) as u8); + if n < 255 { + break; + } + n -= 255; + } + out.extend_from_slice(packet); + out +} + /// The PMT descriptors recorded for `name` in the `mpegts` section, if any. fn track_descriptors(mpegts: &catalog::Mpegts, name: &str) -> Vec { mpegts diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 23cc8cf771..16635eb57f 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -9,7 +9,7 @@ use std::io::Cursor; use bytes::{Bytes, BytesMut}; -use hang::catalog::{AAC, AudioConfig, Container, H264, VideoConfig}; +use hang::catalog::{AAC, AudioCodec, AudioConfig, Container, H264, VideoConfig}; use mpeg2ts::es::StreamType; use mpeg2ts::pes::{PesPacketReader, ReadPesPacket}; use mpeg2ts::ts::{ReadTsPacket, TsPacketReader, TsPayload}; @@ -1290,3 +1290,177 @@ async fn scte35_fixtures_survive_roundtrip() { ); } } + +/// A raw Opus packet: a one-byte TOC (config 1 = SILK NB 20 ms, stereo, code 0) plus +/// `len` filler bytes, so it parses as one 20 ms frame. +fn opus_packet(fill: u8, len: usize) -> Bytes { + let mut p = vec![(1 << 3) | (1 << 2)]; // TOC: config=1, s=1 (stereo), c=0 + p.extend(std::iter::repeat_n(fill, len)); + Bytes::from(p) +} + +/// Strip the Opus-in-TS control header from a PES payload, returning the raw packets it +/// carries. Assumes no trim / no control extension (what the exporter emits). +fn strip_opus_control(mut data: &[u8]) -> Vec> { + let mut packets = Vec::new(); + while !data.is_empty() { + assert_eq!(data[0], 0x7f, "control header sync byte 0"); + assert_eq!(data[1] & 0xe0, 0xe0, "control header sync byte 1"); + assert_eq!(data[1] & 0x1c, 0x00, "exporter emits no trim / extension flags"); + let mut pos = 2; + let mut size = 0usize; + loop { + let b = data[pos]; + pos += 1; + size += b as usize; + if b != 0xff { + break; + } + } + packets.push(data[pos..pos + size].to_vec()); + data = &data[pos + size..]; + } + packets +} + +/// Export an Opus broadcast and assert the program tables advertise a private-data +/// (0x06) stream carrying the 'Opus' registration + DVB extension descriptors, and that +/// the control-header-wrapped PES recovers the raw Opus packets with the right PTS. +#[tokio::test(start_paused = true)] +async fn export_opus_roundtrip() { + 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(".opus"), + moq_net::TrackInfo::default().with_timescale(hang::container::TIMESCALE), + ) + .unwrap(); + let name = track.name().to_string(); + { + let mut cfg = AudioConfig::new(AudioCodec::Opus, 48_000, 2); + cfg.container = Container::Legacy; + catalog.lock().audio.renditions.insert(name.clone(), cfg); + } + let mut producer = Producer::new(track, HangContainer::Legacy); + + // The last packet is > 184 bytes to force PES splitting across TS packets. + let packets: Vec = vec![opus_packet(0x01, 4), opus_packet(0x10, 8), opus_packet(0x20, 200)]; + for (i, payload) in packets.iter().enumerate() { + producer + .write(Frame { + timestamp: Timestamp::from_micros(i as u64 * 20_000).unwrap(), + duration: None, + payload: payload.clone(), + keyframe: true, + }) + .unwrap(); + producer.finish_group().unwrap(); + } + producer.finish().unwrap(); + + let ts = drain(consumer).await; + assert_packet_aligned(&ts); + + // Pass 1: one private-data stream with the Opus registration + extension descriptors. + let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref())); + let mut saw_pmt = false; + while let Some(packet) = reader.read_ts_packet().unwrap() { + if let Some(TsPayload::Pmt(pmt)) = packet.payload { + saw_pmt = true; + assert_eq!(pmt.es_info.len(), 1); + let es = &pmt.es_info[0]; + assert_eq!(es.stream_type, StreamType::from_u8(0x06).unwrap()); + let reg = es.descriptors.iter().find(|d| d.tag == 0x05).expect("registration"); + assert_eq!(reg.data, b"Opus"); + let ext = es.descriptors.iter().find(|d| d.tag == 0x7f).expect("extension"); + assert_eq!(ext.data, vec![0x80, 0x02], "ext tag 0x80 + stereo channel_config_code"); + } + } + assert!(saw_pmt, "missing PMT"); + + // Pass 2: reassemble PES packets and recover the raw Opus packets. + let mut pes = PesPacketReader::new(TsPacketReader::new(Cursor::new(ts.as_ref()))); + let mut recovered: Vec<(u64, Vec)> = Vec::new(); + while let Some(packet) = pes.read_pes_packet().unwrap() { + assert_eq!( + packet.header.stream_id.as_u8(), + mpeg2ts::es::StreamId::PRIVATE_STREAM_1, + "Opus rides private_stream_1" + ); + let pts = packet.header.pts.expect("PES carried no PTS").as_u64(); + for raw in strip_opus_control(&packet.data) { + recovered.push((pts, raw)); + } + } + + assert_eq!(recovered.len(), packets.len()); + for (i, payload) in packets.iter().enumerate() { + let (pts, raw) = &recovered[i]; + assert_eq!(*pts, i as u64 * 20 * 90, "PTS should be ms * 90 (90 kHz)"); + assert_eq!(raw.as_slice(), payload.as_ref(), "raw Opus payload mismatch"); + } +} + +/// Round-trip an Opus broadcast through TS and back: export, re-import, and confirm the +/// catalog surfaces one 48 kHz Opus track whose frames recover the original packets. +#[tokio::test(start_paused = true)] +async fn opus_export_import_roundtrip() { + 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(".opus"), + moq_net::TrackInfo::default().with_timescale(hang::container::TIMESCALE), + ) + .unwrap(); + let name = track.name().to_string(); + { + let mut cfg = AudioConfig::new(AudioCodec::Opus, 48_000, 2); + cfg.container = Container::Legacy; + catalog.lock().audio.renditions.insert(name.clone(), cfg); + } + let mut producer = Producer::new(track, HangContainer::Legacy); + + let packets: Vec = (0..4).map(|i| opus_packet(0x40 + i as u8, 24)).collect(); + for (i, payload) in packets.iter().enumerate() { + producer + .write(Frame { + timestamp: Timestamp::from_micros(i as u64 * 20_000).unwrap(), + duration: None, + payload: payload.clone(), + keyframe: true, + }) + .unwrap(); + producer.finish_group().unwrap(); + } + producer.finish().unwrap(); + + let ts = drain(consumer).await; + + // Re-import the TS we just produced. + let mut imported = moq_net::BroadcastInfo::new().produce(); + let imported_consumer = imported.consume(); + let import_catalog = crate::catalog::Producer::new(&mut imported).unwrap(); + let mut import = crate::container::ts::Import::new(imported, import_catalog.clone()); + import.decode(&ts).unwrap(); + import.finish().unwrap(); + + let snapshot = import_catalog.snapshot(); + assert_eq!(snapshot.audio.renditions.len(), 1, "expected one Opus track"); + let (opus_name, audio) = snapshot.audio.renditions.iter().next().unwrap(); + assert_eq!(audio.codec.to_string(), "opus"); + assert_eq!(audio.sample_rate, 48_000); + assert_eq!(audio.channel_count, 2); + + // The imported packets must match what we published. + let recovered = read_frames(&imported_consumer, opus_name).await; + assert_eq!(recovered.len(), packets.len(), "frame count"); + for (orig, got) in packets.iter().zip(&recovered) { + assert_eq!(got.as_slice(), orig.as_ref(), "Opus packet survived the round-trip"); + } +} diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index 798d43f828..eff31ae54b 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -14,6 +14,7 @@ use std::collections::{HashMap, HashSet}; use std::io::Read; use std::sync::{Arc, Mutex}; +use anyhow::Context; use bytes::BytesMut; use mpeg2ts::es::StreamType; use mpeg2ts::pes::PesHeader; @@ -23,7 +24,7 @@ use mpeg2ts::ts::{Pid, ReadTsPacket, TsPacket, TsPacketReader, TsPayload}; use super::adts; use super::catalog; use crate::catalog::hang::CatalogExt; -use crate::codec::{aac, ac3, eac3, h264, h265, legacy, mp2}; +use crate::codec::{aac, ac3, eac3, h264, h265, legacy, mp2, opus}; use moq_net::Timestamp; /// Demuxes an MPEG-TS byte stream into a MoQ broadcast. @@ -323,6 +324,21 @@ impl Import { StreamType::Mpeg1Audio | StreamType::Mpeg2HalvedSampleRateAudio => self.legacy_stream(&mp2::DESCRIPTOR), StreamType::DolbyDigitalUpToSixChannelAudio => self.legacy_stream(&ac3::DESCRIPTOR), StreamType::DolbyDigitalPlusUpTo16ChannelAudioForAtsc => self.legacy_stream(&eac3::DESCRIPTOR), + // Opus rides private-data PES (0x06), distinguished from other private streams + // by an 'Opus' registration descriptor. Channels and the (always 48 kHz) rate + // come from the descriptors, so the importer is built up front. + StreamType::Mpeg2PacketizedData if registration_format(descriptors) == Some(*b"Opus") => { + let channel_count = opus_channel_count(descriptors).unwrap_or(2); + let track = crate::import::unique_track(&mut self.broadcast, ".opus")?; + let config = opus::Config { + sample_rate: 48_000, + channel_count, + }; + Stream::Opus(Box::new(OpusStream { + import: opus::Import::new(track, self.catalog.clone(), config)?, + unwrap: PtsUnwrap::default(), + })) + } 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 @@ -961,6 +977,7 @@ enum Stream { unwrap: PtsUnwrap, }, Aac(Box>), + Opus(Box>), Legacy(Box>), /// A codec we don't decode, carried verbatim as PES (DTS audio, private PES, ...). Verbatim(Box>), @@ -1003,6 +1020,7 @@ impl Stream { Ok(()) } Stream::Aac(stream) => stream.write(pending, burst), + Stream::Opus(stream) => stream.write(pending), Stream::Legacy(stream) => stream.write(pending), Stream::Verbatim(stream) => stream.write(pending), Stream::Clock | Stream::Ignored => Ok(()), @@ -1020,6 +1038,7 @@ impl Stream { Ok(import.seek(sequence)?) } Stream::Aac(stream) => stream.seek(sequence), + Stream::Opus(stream) => stream.seek(sequence), Stream::Legacy(stream) => stream.seek(sequence), Stream::Verbatim(stream) => stream.seek(sequence), Stream::Clock | Stream::Ignored => Ok(()), @@ -1031,6 +1050,7 @@ impl Stream { Stream::H264 { import, .. } => Ok(import.finish()?), Stream::H265 { import, .. } => Ok(import.finish()?), Stream::Aac(stream) => stream.finish(), + Stream::Opus(stream) => stream.finish(), Stream::Legacy(stream) => stream.finish(), Stream::Verbatim(stream) => stream.finish(), Stream::Clock | Stream::Ignored => Ok(()), @@ -1044,6 +1064,7 @@ impl Stream { 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::Opus(stream) => Some(stream.import.name().to_string()), Stream::Legacy(stream) => stream.import.as_ref().map(|i| i.name().to_string()), Stream::Verbatim(_) | Stream::Clock | Stream::Ignored => None, } @@ -1170,6 +1191,125 @@ impl AacStream { } } +/// One Opus elementary stream. The channels come from the PMT descriptors and the rate +/// is always 48 kHz, so (unlike AAC) the importer is built up front. A PES carries one or +/// more Opus packets, each prefixed by the Opus-in-TS control header. +struct OpusStream { + import: opus::Import, + unwrap: PtsUnwrap, +} + +impl OpusStream { + fn write(&mut self, pending: Pending) -> anyhow::Result<()> { + let base = unwrap_pts(&mut self.unwrap, pending.pts)?; + + let data = &pending.data; + let mut offset = 0; + // 48 kHz samples elapsed since this PES's PTS, advancing each packet after the first. + let mut elapsed: u64 = 0; + while offset < data.len() { + let (header_len, size) = parse_opus_control_header(&data[offset..])?; + let start = offset + header_len; + let end = start + size; + anyhow::ensure!(end <= data.len(), "Opus access unit exceeds PES payload"); + let packet = &data[start..end]; + + let pts = match base { + Some(base) if elapsed > 0 => { + let advance = Timestamp::from_scale(elapsed, 48_000)?; + // `base` is a 90 kHz PTS; rescale the sample advance to match before + // adding (the scale-aware Timestamp rejects mixed scales). + Some(base + advance.convert(base.scale())?) + } + other => other, + }; + self.import.decode(packet, pts)?; + + // Default to 20 ms (960 samples) if the TOC can't be read, so a malformed packet + // doesn't stall the timeline for the rest of the PES. + elapsed += opus::packet_samples(packet).unwrap_or(960) as u64; + offset = end; + } + Ok(()) + } + + fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { + Ok(self.import.seek(sequence)?) + } + + fn finish(&mut self) -> anyhow::Result<()> { + Ok(self.import.finish()?) + } +} + +/// The 4-byte registration `format_identifier` from a PMT registration descriptor +/// (tag 0x05), if present. Identifies the codec of a private-data (0x06) stream. +fn registration_format(descriptors: &[mpeg2ts::ts::Descriptor]) -> Option<[u8; 4]> { + descriptors + .iter() + .find(|d| d.tag == 0x05) + .and_then(|d| d.data.get(..4)) + .and_then(|s| s.try_into().ok()) +} + +/// The Opus channel count from the DVB extension descriptor (tag 0x7f, ext tag 0x80). +/// +/// `channel_config_code` follows the Opus-in-TS mapping (and ffmpeg's demuxer): 0 is dual +/// mono (decoded as stereo), 1..=8 is the channel count directly. Higher codes (0x81 +/// explicitly-coded layouts, reserved values) aren't supported, so they fall back to the +/// caller's default rather than being read as a raw 129..=255 count. +fn opus_channel_count(descriptors: &[mpeg2ts::ts::Descriptor]) -> Option { + descriptors + .iter() + .find(|d| d.tag == 0x7f && d.data.first() == Some(&0x80)) + .and_then(|d| d.data.get(1)) + .and_then(|&cc| match cc { + 0 => Some(2), + 1..=8 => Some(cc as u32), + _ => None, + }) +} + +/// Parse one Opus-in-TS access-unit control header, returning `(header_len, payload_size)`. +fn parse_opus_control_header(data: &[u8]) -> anyhow::Result<(usize, usize)> { + anyhow::ensure!(data.len() >= 2, "Opus control header truncated"); + // 11-bit 0x3FF sync: byte 0 == 0x7F and the top 3 bits of byte 1 == 0b111. + anyhow::ensure!( + data[0] == 0x7f && (data[1] & 0xe0) == 0xe0, + "invalid Opus control header sync (0x{:02x}{:02x})", + data[0], + data[1] + ); + let start_trim = (data[1] & 0x10) != 0; + let end_trim = (data[1] & 0x08) != 0; + let control_ext = (data[1] & 0x04) != 0; + + let mut pos = 2; + // au_size: sum a run of 0xFF bytes plus the final byte < 0xFF. + let mut size = 0usize; + loop { + let b = *data.get(pos).context("Opus au_size truncated")?; + pos += 1; + size += b as usize; + if b != 0xff { + break; + } + } + // Each trim field is 16 bits; the control extension is a length byte plus that many bytes. + if start_trim { + pos += 2; + } + if end_trim { + pos += 2; + } + if control_ext { + let len = *data.get(pos).context("Opus control extension truncated")? as usize; + pos += 1 + len; + } + anyhow::ensure!(pos <= data.len(), "Opus control header exceeds payload"); + Ok((pos, size)) +} + /// One stream of legacy broadcast audio (MP2, AC-3, E-AC-3), carried verbatim: /// whole self-describing frames, split out of the PES by the codec's header /// parser. Like AAC, import creation is deferred until the first frame header diff --git a/rs/moq-mux/src/container/ts/import_test.rs b/rs/moq-mux/src/container/ts/import_test.rs index 9de6694c61..444b576731 100644 --- a/rs/moq-mux/src/container/ts/import_test.rs +++ b/rs/moq-mux/src/container/ts/import_test.rs @@ -102,6 +102,62 @@ fn import_ac3_catalog() { assert!(audio.description.is_none(), "verbatim AC-3 needs no description"); } +/// `opus.ts` is an ffmpeg-authored audio-only Opus program (private stream_type 0x06 +/// plus the 'Opus' registration and DVB extension descriptors), generated with: +/// `ffmpeg -f lavfi -i sine=frequency=440:sample_rate=48000:duration=0.5 +/// -ac 2 -c:a libopus -b:a 96k -f mpegts opus.ts`. It validates our importer against +/// real ffmpeg control-header framing, not just our own exporter's. +#[test] +fn import_opus_catalog() { + let data = include_bytes!("test_data/opus.ts"); + let catalog = import_ts(data); + + assert_eq!(catalog.video.renditions.len(), 0); + assert_eq!(catalog.audio.renditions.len(), 1, "expected one Opus track"); + let audio = catalog.audio.renditions.values().next().unwrap(); + assert_eq!(audio.codec.to_string(), "opus"); + assert_eq!(audio.sample_rate, 48_000, "Opus is always reckoned at 48 kHz"); + assert_eq!(audio.channel_count, 2); +} + +/// Opus frames from real ffmpeg output must decode: a non-empty run of Opus packets, +/// each a plausible size (the control header was stripped, not left in the payload). +#[tokio::test(start_paused = true)] +async fn import_opus_frames() { + let data = include_bytes!("test_data/opus.ts"); + + let mut broadcast = moq_net::BroadcastInfo::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = crate::container::ts::Import::new(broadcast, catalog.clone()); + import.decode(&BytesMut::from(&data[..])).unwrap(); + import.finish().unwrap(); + + let name = catalog + .snapshot() + .audio + .renditions + .keys() + .next() + .expect("an Opus track") + .clone(); + + let track = consumer.track(&name).unwrap().subscribe(None).await.unwrap(); + let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy); + let mut frames = Vec::new(); + while let Ok(res) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await { + let Some(frame) = res.unwrap() else { break }; + frames.push(frame.payload); + } + + assert!(frames.len() > 5, "expected a run of Opus packets, got {}", frames.len()); + for frame in &frames { + assert!(!frame.is_empty(), "Opus packet must not be empty"); + // The Opus-in-TS control header starts with 0x7f; a stripped packet must not. + assert_ne!(frame[0], 0x7f, "control header was not stripped"); + } +} + /// `eac3.ts` is an ffmpeg-authored audio-only ATSC E-AC-3 program (stream_type /// 0x87 plus the 'EAC3' registration descriptor), regenerated with: /// `ffmpeg -f lavfi -i sine=frequency=440:sample_rate=48000:duration=0.5 diff --git a/rs/moq-mux/src/container/ts/test_data/opus.ts b/rs/moq-mux/src/container/ts/test_data/opus.ts new file mode 100644 index 0000000000..2ae5ba579b Binary files /dev/null and b/rs/moq-mux/src/container/ts/test_data/opus.ts differ diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index a6e66f0d66..608712debc 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -1,6 +1,6 @@ /// Errors from moq-mux operations. /// -/// Most variants are delegations to underlying layers — [`moq_net::Error`] for +/// Most variants are delegations to underlying layers: [`moq_net::Error`] for /// transport / pub-sub failures, [`hang::Error`] for catalog/codec parsing, the /// per-format Errors for container shape problems, and the per-codec Errors for /// bitstream parsing problems. @@ -47,6 +47,14 @@ pub enum Error { #[error("opus: {0}")] Opus(#[from] crate::codec::opus::Error), + /// Error parsing FLAC. + #[error("flac: {0}")] + Flac(#[from] crate::codec::flac::Error), + + /// Error parsing MP3. + #[error("mp3: {0}")] + Mp3(#[from] crate::codec::mp3::Error), + /// Error parsing H.264. #[error("h264: {0}")] H264(#[from] crate::codec::h264::Error), diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 64216b8466..9b8bbbf376 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -101,6 +101,8 @@ enum TrackKind { Vp9(crate::codec::vp9::Import), Aac(crate::codec::aac::Import), Opus(crate::codec::opus::Import), + Mp3(crate::codec::mp3::Import), + Flac(crate::codec::flac::Import), } /// A single-codec importer for whole frames. @@ -168,6 +170,13 @@ impl Track { let import = crate::codec::opus::Import::new(track, catalog, config)?; TrackKind::Opus(import) } + "flac" => { + // `init` is a FLAC header: the `fLaC` marker plus the STREAMINFO block. + let mut data = init; + let config = crate::codec::flac::Config::parse(&mut data)?; + let import = crate::codec::flac::Import::new(track, catalog, config)?; + TrackKind::Flac(import) + } _ => return Err(crate::Error::UnknownFormat(format.to_string())), }; @@ -215,6 +224,8 @@ impl Track { TrackKind::Vp9(ref mut import) => import.decode(frame, pts)?, TrackKind::Aac(ref mut import) => import.decode(frame, pts)?, TrackKind::Opus(ref mut import) => import.decode(frame, pts)?, + TrackKind::Mp3(ref mut import) => import.decode(frame, pts)?, + TrackKind::Flac(ref mut import) => import.decode(frame, pts)?, } Ok(()) @@ -231,6 +242,8 @@ impl Track { TrackKind::Vp9(ref mut import) => import.finish(), TrackKind::Aac(ref mut import) => import.finish(), TrackKind::Opus(ref mut import) => import.finish(), + TrackKind::Mp3(ref mut import) => import.finish(), + TrackKind::Flac(ref mut import) => import.finish(), } } @@ -263,6 +276,8 @@ impl Track { TrackKind::Vp9(ref mut import) => import.seek(sequence), TrackKind::Aac(ref mut import) => import.seek(sequence), TrackKind::Opus(ref mut import) => import.seek(sequence), + TrackKind::Mp3(ref mut import) => import.seek(sequence), + TrackKind::Flac(ref mut import) => import.seek(sequence), } } @@ -277,6 +292,8 @@ impl Track { TrackKind::Vp9(ref import) => import.demand(), TrackKind::Aac(ref import) => import.demand(), TrackKind::Opus(ref import) => import.demand(), + TrackKind::Mp3(ref import) => import.demand(), + TrackKind::Flac(ref import) => import.demand(), } } @@ -305,6 +322,17 @@ impl From> for Track { } } +// Lift an already-built mp3 importer into a `Track` so callers that build their +// config out-of-band (e.g. moq-gst, which reads rate/channels from gstreamer caps +// rather than parsing a frame header) can keep using `.into()`. +impl From> for Track { + fn from(mp3: crate::codec::mp3::Import) -> Self { + Self { + kind: TrackKind::Mp3(mp3), + } + } +} + enum TrackStreamKind { /// H.264 in avc3 wire shape (Annex-B with inline SPS/PPS). The split owns /// byte parsing; the import publishes. diff --git a/rs/moq-mux/src/lib.rs b/rs/moq-mux/src/lib.rs index 6d1242ab52..f76cacb65b 100644 --- a/rs/moq-mux/src/lib.rs +++ b/rs/moq-mux/src/lib.rs @@ -14,6 +14,8 @@ //! the JSON manifest listing every track and how to decode it. //! - [`import`](mod@import) is the front door for callers who only have //! a format string. It picks the right concrete importer for you. +//! - [`select`] picks which renditions of a broadcast to keep, on either +//! the import or the consume side. pub mod catalog; mod clock; @@ -21,6 +23,12 @@ pub mod codec; pub mod container; mod error; pub mod import; +pub mod select; pub use clock::Clock; pub use error::*; + +/// Re-export of the [`mp4_atom`] crate, whose types appear in the public CMAF +/// surface ([`container::fmp4`]). A major version bump of `mp4_atom` is a +/// breaking change for moq-mux. +pub use mp4_atom; diff --git a/rs/moq-mux/src/select.rs b/rs/moq-mux/src/select.rs new file mode 100644 index 0000000000..e4efb73a05 --- /dev/null +++ b/rs/moq-mux/src/select.rs @@ -0,0 +1,246 @@ +//! Track selection. +//! +//! [`Broadcast`] picks which renditions of a broadcast to keep. It is purely +//! additive: a default [`Broadcast`] selects *nothing*, and you opt a role in with +//! [`video`](Broadcast::video) / [`audio`](Broadcast::audio). Within an opted-in +//! role, an empty field matches everything; listing values keeps renditions matching +//! any one of them (a union within a field, intersected across fields). +//! +//! The same [`Broadcast`] drives selection at either end of the pipeline: narrowing +//! a published catalog on the consume side (see [`catalog::Select`](crate::catalog::Select)), +//! or choosing which tracks to publish on the import side. + +use hang::catalog::{AudioCodecKind, AudioConfig, VideoCodecKind, VideoConfig}; + +use crate::catalog::hang::{Catalog, CatalogExt}; + +/// Which renditions of a broadcast to keep. +/// +/// Defaults to selecting nothing. Opt a role in with [`video`](Self::video) / +/// [`audio`](Self::audio); an unselected role is dropped entirely. +#[derive(Clone, Debug, Default)] +pub struct Broadcast { + video: Option