├── .editorconfig
├── .github
├── dependabot.yml
├── logo.svg
└── workflows
│ ├── docker.yml
│ ├── js.yml
│ ├── release.yml
│ └── rust.yml
├── .gitignore
├── LICENSE-APACHE
├── LICENSE-MIT
├── README.md
├── biome.jsonc
├── js
├── .dockerignore
├── Dockerfile
├── api-extractor.json
├── biome.jsonc
├── hang-demo
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── announce.html
│ │ ├── announce.tsx
│ │ ├── favicon.svg
│ │ ├── highlight.ts
│ │ ├── index.css
│ │ ├── index.html
│ │ ├── index.ts
│ │ ├── publish.html
│ │ └── publish.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ └── vite.config.ts
├── hang
│ ├── README.md
│ ├── api-extractor.json
│ ├── package.json
│ ├── scripts
│ │ └── release.ts
│ ├── src
│ │ ├── catalog
│ │ │ ├── audio.ts
│ │ │ ├── broadcast.ts
│ │ │ ├── index.ts
│ │ │ ├── track.ts
│ │ │ └── video.ts
│ │ ├── connection.ts
│ │ ├── container
│ │ │ ├── decoder.ts
│ │ │ ├── frame.ts
│ │ │ ├── index.ts
│ │ │ └── vint.ts
│ │ ├── index.ts
│ │ ├── publish
│ │ │ ├── audio.ts
│ │ │ ├── broadcast.ts
│ │ │ ├── controls.tsx
│ │ │ ├── element.tsx
│ │ │ ├── index.ts
│ │ │ ├── publish.ts
│ │ │ └── video.ts
│ │ ├── support
│ │ │ ├── element.tsx
│ │ │ └── index.tsx
│ │ └── watch
│ │ │ ├── audio.ts
│ │ │ ├── broadcast.ts
│ │ │ ├── controls.tsx
│ │ │ ├── element.tsx
│ │ │ ├── index.ts
│ │ │ └── video.ts
│ └── tsconfig.json
├── justfile
├── moq
│ ├── README.md
│ ├── api-extractor.json
│ ├── package.json
│ ├── scripts
│ │ └── release.ts
│ ├── src
│ │ ├── announced.ts
│ │ ├── broadcast.ts
│ │ ├── connection.ts
│ │ ├── group.ts
│ │ ├── index.ts
│ │ ├── publisher.ts
│ │ ├── subscriber.ts
│ │ ├── track.test.ts
│ │ ├── track.ts
│ │ ├── util
│ │ │ ├── error.ts
│ │ │ ├── index.ts
│ │ │ └── watch.ts
│ │ └── wire
│ │ │ ├── announce.ts
│ │ │ ├── group.ts
│ │ │ ├── index.ts
│ │ │ ├── session.ts
│ │ │ ├── stream.ts
│ │ │ └── subscribe.ts
│ └── tsconfig.json
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── signals
│ ├── README.md
│ ├── package.json
│ ├── scripts
│ │ └── release.ts
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
└── tsconfig.json
├── justfile
└── rs
├── .cargo
└── config.toml
├── .dockerignore
├── .release-plz.toml
├── .rustfmt.toml
├── Cargo.lock
├── Cargo.toml
├── Dockerfile
├── hang-bbb
├── hang-cli
├── CHANGELOG.md
├── Cargo.toml
└── src
│ ├── client.rs
│ ├── config.rs
│ ├── main.rs
│ └── server.rs
├── hang-gst
├── CHANGELOG.md
├── Cargo.toml
├── README.md
├── build.rs
└── src
│ ├── lib.rs
│ ├── sink
│ ├── imp.rs
│ └── mod.rs
│ └── source
│ ├── imp.rs
│ └── mod.rs
├── hang-wasm
├── CHANGELOG.md
├── Cargo.lock
├── Cargo.toml
├── README.md
├── hang-bbb
├── package.json
├── pnpm-lock.yaml
├── rspack.config.mjs
├── src
│ ├── bridge.rs
│ ├── bridge.ts
│ ├── connect.rs
│ ├── demo
│ │ ├── index.ts
│ │ ├── publish.html
│ │ └── watch.html
│ ├── error.rs
│ ├── index.ts
│ ├── lib.rs
│ ├── message.rs
│ ├── message.ts
│ ├── publish
│ │ ├── audio.rs
│ │ ├── index.ts
│ │ ├── message.rs
│ │ ├── message.ts
│ │ ├── mod.rs
│ │ └── video.rs
│ ├── watch
│ │ ├── audio.rs
│ │ ├── index.ts
│ │ ├── message.rs
│ │ ├── message.ts
│ │ ├── mod.rs
│ │ └── video.rs
│ └── worklet
│ │ ├── index.ts
│ │ ├── message.rs
│ │ ├── message.ts
│ │ └── mod.rs
└── tsconfig.json
├── hang
├── CHANGELOG.md
├── Cargo.toml
└── src
│ ├── audio
│ ├── aac.rs
│ ├── codec.rs
│ └── mod.rs
│ ├── broadcast.rs
│ ├── catalog.rs
│ ├── cmaf
│ ├── error.rs
│ ├── export.rs
│ ├── import.rs
│ └── mod.rs
│ ├── error.rs
│ ├── frame.rs
│ ├── group.rs
│ ├── lib.rs
│ ├── room.rs
│ ├── track.rs
│ └── video
│ ├── av1.rs
│ ├── codec.rs
│ ├── h264.rs
│ ├── h265.rs
│ ├── mod.rs
│ └── vp9.rs
├── justfile
├── moq-clock
├── CHANGELOG.md
├── Cargo.toml
└── src
│ ├── clock.rs
│ └── main.rs
├── moq-native
├── CHANGELOG.md
├── Cargo.toml
└── src
│ ├── lib.rs
│ ├── log.rs
│ ├── quic.rs
│ └── tls.rs
├── moq-relay
├── CHANGELOG.md
├── Cargo.toml
├── README.md
└── src
│ ├── cluster.rs
│ ├── connection.rs
│ ├── main.rs
│ └── web.rs
├── moq
├── CHANGELOG.md
├── Cargo.toml
├── README.md
└── src
│ ├── coding
│ ├── decode.rs
│ ├── encode.rs
│ ├── mod.rs
│ ├── size.rs
│ └── varint.rs
│ ├── error.rs
│ ├── lib.rs
│ ├── message
│ ├── announce.rs
│ ├── extensions.rs
│ ├── frame.rs
│ ├── group.rs
│ ├── mod.rs
│ ├── session.rs
│ ├── setup.rs
│ ├── stream.rs
│ ├── subscribe.rs
│ └── versions.rs
│ ├── model
│ ├── broadcast.rs
│ ├── frame.rs
│ ├── group.rs
│ ├── mod.rs
│ ├── origin.rs
│ └── track.rs
│ └── session
│ ├── mod.rs
│ ├── publisher.rs
│ ├── reader.rs
│ ├── stream.rs
│ ├── subscriber.rs
│ └── writer.rs
└── rust-toolchain.toml
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | trim_trailing_whitespace = true
7 | insert_final_newline = true
8 | indent_style = tab
9 | indent_size = 4
10 | max_line_length = 120
11 |
12 | # YAML doesn't support hard tabs 🙃
13 | [**.yml]
14 | indent_style = space
15 | indent_size = 2
16 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "github-actions"
9 | directory: "/"
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'moq-relay-v*'
7 | - 'moq-clock-v*'
8 | - 'hang-v*'
9 |
10 | env:
11 | REGISTRY: docker.io/kixelated
12 |
13 | jobs:
14 | deploy:
15 | name: Release
16 |
17 | runs-on: ubuntu-latest
18 | permissions:
19 | contents: read
20 | packages: write
21 | id-token: write
22 |
23 | steps:
24 | - uses: actions/checkout@v4
25 |
26 | # Figure out the docker image based on the tag
27 | - id: parse
28 | run: |
29 | ref=${GITHUB_REF#refs/tags/}
30 | if [[ "$ref" =~ ^([a-z-]+)-v([0-9.]+)$ ]]; then
31 | target=${BASH_REMATCH[1]}
32 | version=${BASH_REMATCH[2]}
33 | echo "target=$target" >> $GITHUB_OUTPUT
34 | echo "version=$version" >> $GITHUB_OUTPUT
35 | else
36 | echo "Tag format not recognized." >&2
37 | exit 1
38 | fi
39 |
40 | # I'm paying for Depot for faster ARM builds.
41 | - uses: depot/setup-action@v1
42 |
43 | # Login to docker.io
44 | - uses: docker/login-action@v3
45 | with:
46 | username: ${{ secrets.DOCKER_USERNAME }}
47 | password: ${{ secrets.DOCKER_PASSWORD }}
48 |
49 | # Build and push Docker image with Depot
50 | - uses: depot/build-push-action@v1
51 | with:
52 | project: r257ctfqm6
53 | file: rs/Dockerfile
54 | context: rs
55 | push: true
56 | target: ${{ steps.parse.outputs.target }}
57 | tags: |
58 | ${{ env.REGISTRY }}/${{ steps.parse.outputs.target }}:${{ steps.parse.outputs.version }}
59 | ${{ env.REGISTRY }}/${{ steps.parse.outputs.target }}:latest
60 | platforms: linux/amd64,linux/arm64
61 |
--------------------------------------------------------------------------------
/.github/workflows/js.yml:
--------------------------------------------------------------------------------
1 | name: Javascript
2 |
3 | permissions:
4 | id-token: write
5 | contents: read
6 |
7 | on:
8 | pull_request:
9 | branches: ["main"]
10 |
11 | env:
12 | CARGO_TERM_COLOR: always
13 |
14 | jobs:
15 | check:
16 | name: Check
17 | runs-on: ubuntu-latest
18 |
19 | steps:
20 | - uses: actions/checkout@v4
21 |
22 | # Use depot/docker to run CI so it's semi-cached.
23 | - uses: depot/setup-action@v1
24 | - uses: depot/build-push-action@v1
25 | with:
26 | project: r257ctfqm6
27 | file: js/Dockerfile
28 | context: js
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | permissions:
4 | pull-requests: write
5 | contents: write
6 |
7 | # Only one release job can run at a time.
8 | concurrency:
9 | group: release
10 | cancel-in-progress: true
11 |
12 | on:
13 | push:
14 | branches:
15 | - main
16 |
17 | jobs:
18 | release:
19 | name: Plz
20 |
21 | runs-on: ubuntu-latest
22 | steps:
23 | # Generating a GitHub token, so that PRs and tags created by
24 | # the release-plz-action can trigger actions workflows.
25 | - name: Generate GitHub token
26 | uses: actions/create-github-app-token@v2
27 | id: generate-token
28 | with:
29 | # GitHub App ID secret name
30 | app-id: ${{ secrets.APP_ID }}
31 | # GitHub App private key secret name
32 | private-key: ${{ secrets.APP_PRIVATE_KEY }}
33 |
34 | # Checkout the repository
35 | - name: Checkout repository
36 | uses: actions/checkout@v4
37 | with:
38 | fetch-depth: 0
39 | token: ${{ steps.generate-token.outputs.token }}
40 |
41 | # Instal Rust
42 | - name: Install Rust toolchain
43 | uses: dtolnay/rust-toolchain@stable
44 |
45 | # Unfortunatly, GStreamer is required for hang-gst to build.
46 | - name: Setup GStreamer
47 | run: |
48 | sudo apt-get update
49 | sudo apt-get remove libunwind-*
50 | sudo apt-get install -y \
51 | libgstreamer1.0-dev \
52 | libgstreamer-plugins-base1.0-dev \
53 | libgstreamer-plugins-bad1.0-dev \
54 | gstreamer1.0-plugins-base \
55 | gstreamer1.0-plugins-good \
56 | gstreamer1.0-plugins-bad \
57 | gstreamer1.0-plugins-ugly \
58 | gstreamer1.0-libav \
59 | gstreamer1.0-tools \
60 | gstreamer1.0-x \
61 | gstreamer1.0-alsa \
62 | gstreamer1.0-gl \
63 | gstreamer1.0-gtk3 \
64 | gstreamer1.0-qt5 \
65 | gstreamer1.0-pulseaudio
66 |
67 | # Run release-plz to create PRs and releases
68 | - name: Release-plz
69 | uses: MarcoIeni/release-plz-action@v0.5
70 | with:
71 | manifest_path: ./rs/Cargo.toml
72 | config: ./rs/.release-plz.toml
73 |
74 | env:
75 | GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
76 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
77 |
--------------------------------------------------------------------------------
/.github/workflows/rust.yml:
--------------------------------------------------------------------------------
1 | name: Rust
2 |
3 | permissions:
4 | id-token: write
5 | contents: read
6 |
7 | on:
8 | pull_request:
9 | branches: ["main"]
10 |
11 | jobs:
12 | check:
13 | name: Check
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v4
18 |
19 | # Use depot/docker to build the binaries and run CI.
20 | - uses: depot/setup-action@v1
21 | - uses: depot/build-push-action@v1
22 | with:
23 | project: r257ctfqm6
24 | file: rs/Dockerfile
25 | context: rs
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Local
2 | .DS_Store
3 | *.local
4 | *.log*
5 |
6 | # IDE
7 | .vscode/*
8 | !.vscode/extensions.json
9 | .idea
10 |
11 | # Build stuff.
12 | target
13 | pkg
14 | dist
15 | out
16 |
17 | # Don't leak dev stuff
18 | *.mp4
19 | *.fmp4
20 | *.crt
21 | *.key
22 | *.hex
23 |
24 | # We're using pnpm
25 | package-lock.json
26 | bun.lockb
27 | yarn.lock
28 | node_modules
29 |
--------------------------------------------------------------------------------
/LICENSE-MIT:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Luke Curley
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/biome.jsonc:
--------------------------------------------------------------------------------
1 | // TODO This should be removed. I just need to find a way for the VSCode plugin to use js/biome.jsonc.
2 | {
3 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
4 | "files": {
5 | // Biome is too dumb to use our .gitignore.
6 | "ignore": ["**/node_modules", "**/dist"]
7 | },
8 | "vcs": {
9 | "enabled": true,
10 | "clientKind": "git",
11 | "useIgnoreFile": true
12 | },
13 | "formatter": {
14 | "enabled": true,
15 | "useEditorconfig": true
16 | },
17 | "organizeImports": { "enabled": true },
18 | "linter": {
19 | "enabled": true,
20 | "rules": {
21 | "a11y": {
22 | "useMediaCaption": "off"
23 | },
24 | "style": {
25 | "useImportType": "off",
26 | "useNodejsImportProtocol": "off"
27 | }
28 | }
29 | },
30 | "javascript": {
31 | "formatter": {
32 | "quoteStyle": "double"
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/js/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/js/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ubuntu:24.04 AS build
2 |
3 | WORKDIR /build
4 | ENV DEBIAN_FRONTEND=noninteractive
5 | ENV PNPM_HOME="/root/.local/share/pnpm"
6 | ENV PATH="$PNPM_HOME:$PATH"
7 |
8 | # Install required tools
9 | RUN apt-get update && apt-get install -y \
10 | curl \
11 | ca-certificates \
12 | gnupg \
13 | lsb-release \
14 | sudo \
15 | just \
16 | build-essential
17 |
18 | # Add NodeSource PPA for latest Node.js
19 | RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
20 | apt-get install -y nodejs
21 |
22 | # Install pnpm so it's cached between builds.
23 | RUN corepack enable && corepack prepare pnpm@10.11.0 --activate
24 |
25 | COPY . .
26 |
27 | # Install deps with cache mount
28 | RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
29 | pnpm -r install --frozen-lockfile
30 |
31 | # Run the CI checks and make sure it builds.
32 | RUN just check --frozen-lockfile && \
33 | just build --frozen-lockfile
34 |
35 | FROM scratch AS final
36 | COPY --from=build /build/hang/dist /dist
--------------------------------------------------------------------------------
/js/biome.jsonc:
--------------------------------------------------------------------------------
1 | // NOTE: There's another biome.jsonc in the root that's used for VSCode until I can fix it.
2 | // This one is used for docker...
3 | {
4 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
5 | "files": {
6 | // Biome is too dumb to use our .gitignore.
7 | "ignore": ["**/node_modules", "**/dist"]
8 | },
9 | "vcs": {
10 | "enabled": true,
11 | "clientKind": "git",
12 | "useIgnoreFile": true
13 | },
14 | "formatter": {
15 | "enabled": true,
16 | "lineWidth": 120,
17 | "indentStyle": "tab",
18 | "indentWidth": 4,
19 | "lineEnding": "lf"
20 | },
21 | "organizeImports": { "enabled": true },
22 | "linter": {
23 | "enabled": true,
24 | "rules": {
25 | "a11y": {
26 | "useMediaCaption": "off"
27 | },
28 | "style": {
29 | "useImportType": "off",
30 | "useNodejsImportProtocol": "off"
31 | }
32 | }
33 | },
34 | "javascript": {
35 | "formatter": {
36 | "quoteStyle": "double"
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/js/hang-demo/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Media over QUIC (MoQ) is a live (media) delivery protocol utilizing QUIC.
6 | It utilizes new browser technologies such as [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API) and [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) to provide WebRTC-like functionality.
7 | Despite the focus on media, the transport is generic and designed to scale to enormous viewership via clustered relay servers (aka a CDN).
8 | See [quic.video](https://quic.video) for more information.
9 |
10 | **Note:** this project is a [fork](https://quic.video/blog/transfork) of the [IETF specification](https://datatracker.ietf.org/group/moq/documents/).
11 | The principles are the same but the implementation is exponentially simpler given a narrower focus (and no politics).
12 |
13 | # Usage
14 | These are demos, duh.
15 | We're using Vite but other bundlers should just work™.
16 |
17 | # License
18 |
19 | Licensed under either:
20 |
21 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
22 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
23 |
--------------------------------------------------------------------------------
/js/hang-demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@kixelated/hang-demo",
3 | "private": true,
4 | "type": "module",
5 | "version": "0.1.0",
6 | "description": "Media over QUIC - Demo",
7 | "license": "(MIT OR Apache-2.0)",
8 | "repository": "github:kixelated/moq",
9 | "scripts": {
10 | "dev": "vite --open",
11 | "check": "tsc --noEmit"
12 | },
13 | "dependencies": {
14 | "@kixelated/moq": "workspace:^0.5.0",
15 | "@kixelated/hang": "workspace:^0.1.2",
16 | "@kixelated/signals": "workspace:^0.1.0",
17 | "solid-js": "^1.9.7"
18 | },
19 | "devDependencies": {
20 | "@tailwindcss/postcss": "^4.1.7",
21 | "@tailwindcss/typography": "^0.5.16",
22 | "@tailwindcss/vite": "^4.1.7",
23 | "highlight.js": "^11.11.1",
24 | "tailwindcss": "^4.1.7",
25 | "typescript": "^5.8.3",
26 | "vite": "^6.3.5",
27 | "vite-plugin-html": "^3.2.2",
28 | "vite-plugin-solid": "^2.11.6"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/js/hang-demo/src/announce.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | MoQ Demo
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Other demos:
21 |
25 |
26 | Tips:
27 |
28 | The internal/origins/hostname
broadcasts are used by the clustering mechanism.
29 | That's right, MoQ relays use MoQ to discover other nodes!
30 | These internal tracks won't be discoverable once authentication is implemented.
31 |
32 |
33 | If you abrubtly kill a broadcast, it can take a few seconds for a connection to fail the keep-alive
34 | check.
35 | The current configuration is a 10 second timeout, then a broadcast will be considered dead.
36 | Kill a broadcast and watch it get removed after a few seconds.
37 |
38 |
39 | The 0s ago is based on the client.
40 | There's no timestamp that gets serialized over the wire; this is just to demonstate that these announcements are
41 | live.
42 |
43 |
44 | Try creating a new broadcast!
45 | The updates are fully live (no polling) so any changes are reflected immediately.
46 | For example, try: just pub tos
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/js/hang-demo/src/highlight.ts:
--------------------------------------------------------------------------------
1 | import hljs from "highlight.js/lib/core";
2 | import bash from "highlight.js/lib/languages/bash";
3 | import css from "highlight.js/lib/languages/css";
4 | import javascript from "highlight.js/lib/languages/javascript";
5 | import typescript from "highlight.js/lib/languages/typescript";
6 | import html from "highlight.js/lib/languages/xml";
7 |
8 | import "highlight.js/styles/atom-one-dark.css";
9 | hljs.configure({
10 | cssSelector: "code",
11 | });
12 | hljs.registerLanguage("javascript", javascript);
13 | hljs.registerLanguage("typescript", typescript);
14 | hljs.registerLanguage("bash", bash);
15 | hljs.registerLanguage("html", html);
16 | hljs.registerLanguage("css", css);
17 | hljs.highlightAll();
18 |
--------------------------------------------------------------------------------
/js/hang-demo/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @plugin "@tailwindcss/typography";
3 |
4 | @custom-variant dark (&:where(.dark, .dark *));
5 |
6 | /* Stop Prism/Tailwind from changing the font size. */
7 | code[class*="language-"],
8 | pre[class*="language-"] {
9 | font-size: 1rem !important;
10 | }
11 |
12 | button {
13 | cursor: pointer;
14 | }
15 |
16 | body {
17 | /* We're using Tailwind for the prose styles. */
18 | @apply prose bg-neutral-950 text-white dark:prose-invert lg:prose-xl mx-auto;
19 | }
20 |
--------------------------------------------------------------------------------
/js/hang-demo/src/index.ts:
--------------------------------------------------------------------------------
1 | import "./index.css";
2 | import "./highlight";
3 |
4 | import HangSupport from "@kixelated/hang/support/element";
5 | import HangWatch from "@kixelated/hang/watch/element";
6 |
7 | export { HangWatch, HangSupport };
8 |
9 | const watch = document.querySelector("hang-watch") as HangWatch;
10 |
11 | // If query params are provided, use it as the broadcast name.
12 | const urlParams = new URLSearchParams(window.location.search);
13 | const name = urlParams.get("name") ?? "demo/bbb";
14 | watch.setAttribute("url", `http://localhost:4443/${name}.hang`);
15 |
--------------------------------------------------------------------------------
/js/hang-demo/src/publish.ts:
--------------------------------------------------------------------------------
1 | import "./index.css";
2 | import "./highlight";
3 |
4 | // We need to import Web Components with fully-qualified paths because of tree-shaking.
5 | import HangPublish from "@kixelated/hang/publish/element";
6 | import HangSupport from "@kixelated/hang/support/element";
7 |
8 | export { HangPublish, HangSupport };
9 |
10 | const publish = document.querySelector("hang-publish") as HangPublish;
11 | const watch = document.getElementById("watch") as HTMLAnchorElement;
12 | const watchName = document.getElementById("watch-name") as HTMLSpanElement;
13 |
14 | const urlParams = new URLSearchParams(window.location.search);
15 | const name = urlParams.get("name") ?? "demo/me";
16 | publish.setAttribute("url", `http://localhost:4443/${name}.hang`);
17 | watch.href = `index.html?name=${name}`;
18 | watchName.textContent = name;
19 |
--------------------------------------------------------------------------------
/js/hang-demo/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // tailwind.config.js
2 | module.exports = {
3 | content: ["./src/*.{html,js,ts,jsx,tsx}"],
4 | plugins: [require("@tailwindcss/typography")],
5 | };
6 |
--------------------------------------------------------------------------------
/js/hang-demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules", "dist"],
5 | "compilerOptions": {
6 | "outDir": "dist",
7 | "jsx": "react-jsx",
8 | "jsxImportSource": "solid-js"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/js/hang-demo/vite.config.ts:
--------------------------------------------------------------------------------
1 | import tailwindcss from "@tailwindcss/vite";
2 | import { defineConfig } from "vite";
3 | import solidPlugin from "vite-plugin-solid";
4 |
5 | export default defineConfig({
6 | root: "src",
7 | plugins: [tailwindcss(), solidPlugin()],
8 | build: {
9 | target: "esnext",
10 | rollupOptions: {
11 | input: {
12 | watch: "index.html",
13 | publish: "publish.html",
14 | announce: "announce.html",
15 | },
16 | },
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/js/hang/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Media over QUIC (MoQ) is a live (media) delivery protocol utilizing QUIC.
6 | It utilizes new browser technologies such as [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API) and [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) to provide WebRTC-like functionality.
7 | Despite the focus on media, the transport is generic and designed to scale to enormous viewership via clustered relay servers (aka a CDN).
8 | See [quic.video](https://quic.video) for more information.
9 |
10 | **Note:** this project is a [fork](https://quic.video/blog/transfork) of the [IETF specification](https://datatracker.ietf.org/group/moq/documents/).
11 | The principles are the same but the implementation is exponentially simpler given a narrower focus (and no politics).
12 |
13 | # Usage
14 | This library contains a lot of media stuff.
15 | More documentation will be available later, until then refer to the code and especially the [demos](../hang-demo).
16 |
17 | ```html
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | ```
30 |
31 | The API is still evolving, so expect breaking changes.
32 |
33 | # License
34 |
35 | Licensed under either:
36 |
37 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
38 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
39 |
--------------------------------------------------------------------------------
/js/hang/api-extractor.json:
--------------------------------------------------------------------------------
1 | /**
2 | * Config file for API Extractor. For more info, please visit: https://api-extractor.com
3 | */
4 | {
5 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
6 | "extends": "../api-extractor.json",
7 | "mainEntryPointFilePath": "dist/index.d.ts"
8 | }
9 |
--------------------------------------------------------------------------------
/js/hang/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@kixelated/hang",
3 | "type": "module",
4 | "version": "0.1.2",
5 | "description": "Media over QUIC library",
6 | "license": "(MIT OR Apache-2.0)",
7 | "repository": "github:kixelated/moq",
8 | "exports": {
9 | ".": "./src/index.ts",
10 | "./publish": "./src/publish/index.ts",
11 | "./publish/element": "./src/publish/element.tsx",
12 | "./watch": "./src/watch/index.ts",
13 | "./watch/element": "./src/watch/element.tsx",
14 | "./catalog": "./src/catalog/index.ts",
15 | "./container": "./src/container/index.ts",
16 | "./support": "./src/support/index.ts",
17 | "./support/element": "./src/support/element.tsx",
18 | "./connection": "./src/connection.ts"
19 | },
20 | "sideEffects": ["./src/publish/element.ts", "./src/watch/element.ts", "./src/support/element.ts"],
21 | "files": ["./src", "./dist", "README.md", "tsconfig.json"],
22 | "scripts": {
23 | "build": "tsc -b",
24 | "check": "tsc --noEmit",
25 | "release": "tsx scripts/release.ts"
26 | },
27 | "dependencies": {
28 | "@kixelated/moq": "workspace:^0.5.0",
29 | "@kixelated/signals": "workspace:^0.1.0",
30 | "buffer": "^6.0.3",
31 | "lodash": "^4.17.21",
32 | "solid-js": "^1.9.7",
33 | "zod": "^3.25.20"
34 | },
35 | "devDependencies": {
36 | "@types/lodash": "^4.17.17",
37 | "@typescript/lib-dom": "npm:@types/web@^0.0.235",
38 | "typescript": "^5.8.3"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/js/hang/scripts/release.ts:
--------------------------------------------------------------------------------
1 | // ChatGPT made a script that rewrites package.json file to use the correct paths.
2 | // It's not pretty but nothing in NPM is.
3 |
4 | import { execSync } from "node:child_process";
5 | import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6 |
7 | console.log("🧹 Cleaning dist/...");
8 | rmSync("dist", { recursive: true, force: true });
9 |
10 | console.log("🛠️ Building...");
11 | execSync("pnpm i && pnpm build", { stdio: "inherit" });
12 |
13 | console.log("✍️ Rewriting package.json...");
14 | const pkg = JSON.parse(readFileSync("package.json", "utf8"));
15 |
16 | function rewritePath(p: string): string {
17 | return p.replace(/^\.\/src/, "./dist").replace(/\.ts(x)?$/, ".js");
18 | }
19 |
20 | pkg.main &&= rewritePath(pkg.main);
21 | pkg.types &&= rewritePath(pkg.types);
22 |
23 | if (pkg.exports) {
24 | for (const key in pkg.exports) {
25 | const val = pkg.exports[key];
26 | if (typeof val === "string") {
27 | pkg.exports[key] = rewritePath(val);
28 | } else if (typeof val === "object") {
29 | for (const sub in val) {
30 | if (typeof val[sub] === "string") {
31 | val[sub] = rewritePath(val[sub]);
32 | }
33 | }
34 | }
35 | }
36 | }
37 |
38 | if (pkg.sideEffects) {
39 | pkg.sideEffects = pkg.sideEffects.map(rewritePath);
40 | }
41 |
42 | // biome-ignore lint/performance/noDelete:
43 | delete pkg.devDependencies;
44 | // biome-ignore lint/performance/noDelete:
45 | delete pkg.scripts;
46 |
47 | // Temporarily swap out the package.json with the one that has the correct paths.
48 | renameSync("package.json", "package.backup.json");
49 | try {
50 | writeFileSync("package.json", JSON.stringify(pkg, null, 2));
51 |
52 | console.log("🚀 Publishing...");
53 | execSync("pnpm publish --access=public --no-git-checks", {
54 | stdio: "inherit",
55 | });
56 | } finally {
57 | renameSync("package.backup.json", "package.json");
58 | }
59 |
--------------------------------------------------------------------------------
/js/hang/src/catalog/audio.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4-mini";
2 |
3 | import { TrackSchema } from "./track";
4 |
5 | // Mirrors AudioDecoderConfig
6 | // https://w3c.github.io/webcodecs/#audio-decoder-config
7 | export const AudioConfigSchema = z.object({
8 | // See: https://w3c.github.io/webcodecs/codec_registry.html
9 | codec: z.string(),
10 |
11 | // The description is used for some codecs.
12 | // If provided, we can initialize the decoder based on the catalog alone.
13 | // Otherwise, the initialization information is in-band.
14 | description: z.optional(z.string()), // hex encoded
15 |
16 | // The sample rate of the audio in Hz
17 | sampleRate: z.uint32(),
18 |
19 | // The number of channels in the audio
20 | numberOfChannels: z.uint32(),
21 |
22 | // The bitrate of the audio in bits per second
23 | // TODO: Support up to Number.MAX_SAFE_INTEGER
24 | bitrate: z.optional(z.uint32()),
25 | });
26 |
27 | export const AudioSchema = z.object({
28 | // The MoQ track information.
29 | track: TrackSchema,
30 |
31 | // The configuration of the audio track
32 | config: AudioConfigSchema,
33 | });
34 |
35 | export type Audio = z.infer;
36 | export type AudioConfig = z.infer;
37 |
--------------------------------------------------------------------------------
/js/hang/src/catalog/broadcast.ts:
--------------------------------------------------------------------------------
1 | import * as Moq from "@kixelated/moq";
2 | import { z } from "zod/v4-mini";
3 |
4 | import { type Audio, AudioSchema } from "./audio";
5 | import { type Video, VideoSchema } from "./video";
6 |
7 | export const BroadcastSchema = z.object({
8 | video: z.optional(z.array(VideoSchema)),
9 | audio: z.optional(z.array(AudioSchema)),
10 | });
11 |
12 | export class Broadcast {
13 | video: Video[] = [];
14 | audio: Audio[] = [];
15 |
16 | encode() {
17 | return JSON.stringify(this);
18 | }
19 |
20 | static decode(raw: Uint8Array): Broadcast {
21 | const decoder = new TextDecoder();
22 | const str = decoder.decode(raw);
23 | const json = JSON.parse(str);
24 | const parsed = BroadcastSchema.parse(json);
25 |
26 | const broadcast = new Broadcast();
27 | broadcast.video = parsed.video ?? [];
28 | broadcast.audio = parsed.audio ?? [];
29 |
30 | return broadcast;
31 | }
32 |
33 | static async fetch(track: Moq.TrackConsumer): Promise {
34 | const group = await track.nextGroup();
35 | if (!group) return undefined; // track is done
36 |
37 | try {
38 | const frame = await group.readFrame();
39 | if (!frame) throw new Error("empty group");
40 | return Broadcast.decode(frame);
41 | } finally {
42 | group.close();
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/js/hang/src/catalog/index.ts:
--------------------------------------------------------------------------------
1 | export type { Audio } from "./audio";
2 | export { Broadcast } from "./broadcast";
3 | export type { Track } from "./track";
4 | export type { Video } from "./video";
5 |
--------------------------------------------------------------------------------
/js/hang/src/catalog/track.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4-mini";
2 |
3 | export const TrackSchema = z.object({
4 | name: z.string(),
5 | priority: z.uint32(),
6 | bitrate: z.optional(z.uint32()),
7 | });
8 |
9 | export type Track = z.infer;
10 |
--------------------------------------------------------------------------------
/js/hang/src/catalog/video.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod/v4-mini";
2 |
3 | import { TrackSchema } from "./track";
4 |
5 | export const VideoConfigSchema = z.object({
6 | // See: https://w3c.github.io/webcodecs/codec_registry.html
7 | codec: z.string(),
8 |
9 | // The description is used for some codecs.
10 | // If provided, we can initialize the decoder based on the catalog alone.
11 | // Otherwise, the initialization information is (repeated) before each key-frame.
12 | description: z.optional(z.string()), // hex encoded
13 |
14 | // The width and height of the video in pixels
15 | codedWidth: z.optional(z.uint32()),
16 | codedHeight: z.optional(z.uint32()),
17 |
18 | // Ratio of display width/height to coded width/height
19 | // Allows stretching/squishing individual "pixels" of the video
20 | // If not provided, the display ratio is 1:1
21 | displayRatioWidth: z.optional(z.uint32()),
22 | displayRatioHeight: z.optional(z.uint32()),
23 |
24 | // The frame rate of the video in frames per second
25 | framerate: z.optional(z.uint32()),
26 |
27 | // The bitrate of the video in bits per second
28 | // TODO: Support up to Number.MAX_SAFE_INTEGER
29 | bitrate: z.optional(z.uint32()),
30 |
31 | // If true, the decoder will optimize for latency.
32 | // Default: true
33 | optimizeForLatency: z.optional(z.boolean()),
34 |
35 | // The rotation of the video in degrees.
36 | // Default: 0
37 | rotation: z.optional(z.number()),
38 |
39 | // If true, the decoder will flip the video horizontally
40 | // Default: false
41 | flip: z.optional(z.boolean()),
42 | });
43 |
44 | // Mirrors VideoDecoderConfig
45 | // https://w3c.github.io/webcodecs/#video-decoder-config
46 | export const VideoSchema = z.object({
47 | // The MoQ track information.
48 | track: TrackSchema,
49 |
50 | // The configuration of the video track
51 | config: VideoConfigSchema,
52 | });
53 |
54 | export type Video = z.infer;
55 | export type VideoConfig = z.infer;
56 |
--------------------------------------------------------------------------------
/js/hang/src/connection.ts:
--------------------------------------------------------------------------------
1 | import * as Moq from "@kixelated/moq";
2 | import { Signal, Signals, signal } from "@kixelated/signals";
3 |
4 | export type ConnectionProps = {
5 | // The URL of the relay server.
6 | url?: URL;
7 |
8 | // Reload the connection when it disconnects.
9 | // default: true
10 | reload?: boolean;
11 |
12 | // The delay in milliseconds before reconnecting.
13 | // default: 1000
14 | delay?: DOMHighResTimeStamp;
15 |
16 | // The maximum delay in milliseconds.
17 | // default: 30000
18 | maxDelay?: number;
19 | };
20 |
21 | export class Connection {
22 | url: Signal;
23 | status = signal<"connecting" | "connected" | "disconnected">("disconnected");
24 | established = signal(undefined);
25 |
26 | readonly reload: boolean;
27 | readonly delay: number;
28 | readonly maxDelay: number;
29 |
30 | #signals = new Signals();
31 | #delay: number;
32 |
33 | // Increased by 1 each time to trigger a reload.
34 | #tick = signal(0);
35 |
36 | constructor(props?: ConnectionProps) {
37 | this.url = signal(props?.url);
38 | this.reload = props?.reload ?? true;
39 | this.delay = props?.delay ?? 1000;
40 | this.maxDelay = props?.maxDelay ?? 30000;
41 |
42 | this.#delay = this.delay;
43 |
44 | // Create a reactive root so cleanup is easier.
45 | this.#signals.effect(() => this.#connect());
46 | }
47 |
48 | #connect() {
49 | // Will retry when the tick changes.
50 | this.#tick.get();
51 |
52 | const url = this.url.get();
53 | if (!url) return;
54 |
55 | this.status.set("connecting");
56 |
57 | (async () => {
58 | try {
59 | const connection = await Moq.Connection.connect(url);
60 | this.established.set(connection);
61 | this.status.set("connected");
62 |
63 | // Reset the exponential backoff on success.
64 | this.#delay = this.delay;
65 |
66 | await connection.closed();
67 | } catch (err) {
68 | console.warn("connection error:", err);
69 |
70 | this.established.set(undefined);
71 | this.status.set("disconnected");
72 |
73 | if (!this.reload) return;
74 | const tick = this.#tick.peek() + 1;
75 |
76 | setTimeout(() => {
77 | this.#tick.set((prev) => Math.max(prev, tick));
78 | }, this.#delay);
79 |
80 | // Exponential backoff.
81 | this.#delay = Math.min(this.#delay * 2, this.maxDelay);
82 | }
83 | })();
84 |
85 | return () => {
86 | this.established.set((prev) => {
87 | prev?.close();
88 | return undefined;
89 | });
90 | this.status.set("disconnected");
91 | };
92 | }
93 |
94 | close() {
95 | this.#signals.close();
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/js/hang/src/container/decoder.ts:
--------------------------------------------------------------------------------
1 | import * as Moq from "@kixelated/moq";
2 | import { Frame } from "./frame";
3 |
4 | export class Decoder {
5 | #track: Moq.TrackConsumer;
6 | #group?: Moq.GroupConsumer;
7 |
8 | #nextGroup?: Promise;
9 | #nextFrame?: Promise ;
10 |
11 | constructor(track: Moq.TrackConsumer) {
12 | this.#track = track;
13 | this.#nextGroup = track.nextGroup();
14 | }
15 |
16 | async readFrame(): Promise {
17 | for (;;) {
18 | if (this.#nextGroup && this.#nextFrame) {
19 | // Figure out if nextFrame or nextGroup wins the race.
20 | // TODO support variable latency
21 | const next: { frame: Frame | undefined } | { group: Moq.GroupConsumer | undefined } =
22 | await Promise.race([
23 | this.#nextFrame.then((frame) => ({ frame })),
24 | this.#nextGroup.then((group) => ({ group })),
25 | ]);
26 |
27 | if ("frame" in next) {
28 | const frame = next.frame;
29 | if (!frame) {
30 | // The group is done, wait for the next one.
31 | this.#nextFrame = undefined;
32 | continue;
33 | }
34 |
35 | // We got a frame; return it.
36 | // But first start fetching the next frame (note: group cannot be undefined).
37 | this.#nextFrame = this.#group ? Frame.decode(this.#group, false) : undefined;
38 | return frame;
39 | }
40 |
41 | const group = next.group;
42 | if (!group) {
43 | // The track is done, but finish with the current group.
44 | this.#nextGroup = undefined;
45 | continue;
46 | }
47 |
48 | // Start fetching the next group.
49 | this.#nextGroup = this.#track.nextGroup();
50 |
51 | if (this.#group && this.#group.id >= group.id) {
52 | // Skip this old group.
53 | console.warn(`skipping old group: ${group.id} < ${this.#group.id}`);
54 | group.close();
55 | continue;
56 | }
57 |
58 | // Skip the rest of the current group.
59 | this.#group?.close();
60 | this.#group = next.group;
61 | this.#nextFrame = this.#group ? Frame.decode(this.#group, true) : undefined;
62 | } else if (this.#nextGroup) {
63 | // Wait for the next group.
64 | const group = await this.#nextGroup;
65 | this.#group?.close();
66 | this.#group = group;
67 |
68 | if (this.#group) {
69 | this.#nextGroup = this.#track.nextGroup();
70 | this.#nextFrame = Frame.decode(this.#group, true);
71 | } else {
72 | this.#nextGroup = undefined;
73 | this.#nextFrame = undefined;
74 | return undefined;
75 | }
76 | } else if (this.#nextFrame) {
77 | // We got the next frame, or potentially the end of the track.
78 | const frame = await this.#nextFrame;
79 | if (!frame) {
80 | this.#group?.close();
81 | this.#group = undefined;
82 |
83 | this.#nextFrame = undefined;
84 | this.#nextGroup = undefined;
85 | return undefined;
86 | }
87 |
88 | this.#nextFrame = this.#group ? Frame.decode(this.#group, false) : undefined;
89 | return frame;
90 | } else {
91 | return undefined;
92 | }
93 | }
94 | }
95 |
96 | close() {
97 | this.#nextFrame = undefined;
98 | this.#nextGroup = undefined;
99 |
100 | this.#group?.close();
101 | this.#group = undefined;
102 |
103 | this.#track.close();
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/js/hang/src/container/frame.ts:
--------------------------------------------------------------------------------
1 | import type { GroupConsumer, GroupProducer } from "@kixelated/moq";
2 | import { getVint53, setVint53 } from "./vint";
3 |
4 | export class Frame {
5 | keyframe: boolean;
6 | timestamp: number;
7 | data: Uint8Array;
8 |
9 | constructor(keyframe: boolean, timestamp: number, data: Uint8Array) {
10 | this.keyframe = keyframe;
11 | this.timestamp = timestamp;
12 | this.data = data;
13 | }
14 |
15 | static async decode(group: GroupConsumer, keyframe: boolean): Promise {
16 | const payload = await group.readFrame();
17 | if (!payload) {
18 | return undefined;
19 | }
20 |
21 | const [timestamp, data] = getVint53(payload);
22 | return new Frame(keyframe, timestamp, data);
23 | }
24 |
25 | encode(group: GroupProducer) {
26 | let frame = new Uint8Array(8 + this.data.byteLength);
27 | const size = setVint53(frame, this.timestamp).byteLength;
28 | frame.set(this.data, size);
29 | frame = new Uint8Array(frame.buffer, 0, this.data.byteLength + size);
30 |
31 | group.writeFrame(frame);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/js/hang/src/container/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./decoder";
2 | export * from "./frame";
3 |
--------------------------------------------------------------------------------
/js/hang/src/container/vint.ts:
--------------------------------------------------------------------------------
1 | const MAX_U6 = 2 ** 6 - 1;
2 | const MAX_U14 = 2 ** 14 - 1;
3 | const MAX_U30 = 2 ** 30 - 1;
4 | const MAX_U53 = Number.MAX_SAFE_INTEGER;
5 | //const MAX_U62: bigint = 2n ** 62n - 1n;
6 |
7 | // QUIC VarInt
8 | export function getVint53(buf: Uint8Array): [number, Uint8Array] {
9 | const size = 1 << ((buf[0] & 0xc0) >> 6);
10 |
11 | const view = new DataView(buf.buffer, buf.byteOffset, size);
12 | const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size);
13 | let v: number;
14 |
15 | if (size === 1) {
16 | v = buf[0] & 0x3f;
17 | } else if (size === 2) {
18 | v = view.getInt16(0) & 0x3fff;
19 | } else if (size === 4) {
20 | v = view.getUint32(0) & 0x3fffffff;
21 | } else if (size === 8) {
22 | // NOTE: Precision loss above 2^52
23 | v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn);
24 | } else {
25 | throw new Error("impossible");
26 | }
27 |
28 | return [v, remain];
29 | }
30 |
31 | export function setVint53(dst: Uint8Array, v: number): Uint8Array {
32 | if (v <= MAX_U6) {
33 | dst[0] = v;
34 | return new Uint8Array(dst.buffer, dst.byteOffset, 1);
35 | }
36 |
37 | if (v <= MAX_U14) {
38 | const view = new DataView(dst.buffer, dst.byteOffset, 2);
39 | view.setUint16(0, v | 0x4000);
40 | return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
41 | }
42 |
43 | if (v <= MAX_U30) {
44 | const view = new DataView(dst.buffer, dst.byteOffset, 4);
45 | view.setUint32(0, v | 0x80000000);
46 | return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
47 | }
48 |
49 | if (v <= MAX_U53) {
50 | const view = new DataView(dst.buffer, dst.byteOffset, 8);
51 | view.setBigUint64(0, BigInt(v) | 0xc000000000000000n);
52 | return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
53 | }
54 |
55 | throw new Error(`overflow, value larger than 53-bits: ${v}`);
56 | }
57 |
--------------------------------------------------------------------------------
/js/hang/src/index.ts:
--------------------------------------------------------------------------------
1 | // TODO This should go into MoQ.
2 | export * from "./connection";
3 |
4 | export * as Catalog from "./catalog";
5 | export * as Publish from "./publish";
6 | export * as Watch from "./watch";
7 | export * as Container from "./container";
8 | export * as Support from "./support";
9 |
--------------------------------------------------------------------------------
/js/hang/src/publish/controls.tsx:
--------------------------------------------------------------------------------
1 | import { Match, Switch, createSelector } from "solid-js";
2 | import { JSX } from "solid-js/jsx-runtime";
3 | import { Device } from "./broadcast";
4 | import { Publish } from "./publish";
5 |
6 | export function Controls(props: { lib: Publish }): JSX.Element {
7 | return (
8 |
17 |
18 |
19 |
20 | );
21 | }
22 |
23 | function Status(props: { lib: Publish }): JSX.Element {
24 | const url = props.lib.connection.url.get;
25 | const status = props.lib.connection.status.get;
26 | const audio = props.lib.broadcast.audio.catalog.get;
27 | const video = props.lib.broadcast.video.catalog.get;
28 |
29 | return (
30 |
31 |
32 | 🔴 No URL
33 | 🔴 Disconnected
34 | 🟡 Connecting...
35 | 🔴 Select Device
36 | 🟡 Video Only
37 | 🟡 Audio Only
38 | 🟢 Live
39 | 🟢 Connected
40 |
41 |
42 | );
43 | }
44 |
45 | function Select(props: { lib: Publish }): JSX.Element {
46 | const setDevice = (device: Device | undefined) => {
47 | props.lib.broadcast.device.set(device);
48 | };
49 |
50 | const selected = createSelector(props.lib.broadcast.device.get);
51 |
52 | const buttonStyle = (id: Device | undefined) => ({
53 | cursor: "pointer",
54 | opacity: selected(id) ? 1 : 0.5,
55 | });
56 |
57 | return (
58 |
59 | Device:
60 | setDevice("camera")}
65 | style={buttonStyle("camera")}
66 | >
67 | 🎥
68 |
69 | setDevice("screen")}
74 | style={buttonStyle("screen")}
75 | >
76 | 🖥️
77 |
78 | setDevice(undefined)}
83 | style={buttonStyle(undefined)}
84 | >
85 | 🚫
86 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/js/hang/src/publish/element.tsx:
--------------------------------------------------------------------------------
1 | import { signal } from "@kixelated/signals";
2 | import { Show, render } from "solid-js/web";
3 | import { Device } from "./broadcast";
4 | import { Controls } from "./controls";
5 | import { Publish } from "./publish";
6 |
7 | export default class HangPublish extends HTMLElement {
8 | static observedAttributes = ["url", "device", "audio", "video", "controls"];
9 |
10 | #controls = signal(false);
11 |
12 | lib: Publish;
13 |
14 | constructor() {
15 | super();
16 |
17 | const preview = this.querySelector("video") as HTMLVideoElement | undefined;
18 |
19 | // The broadcast path is "" because it's relative to the connection URL.
20 | this.lib = new Publish({ preview, broadcast: { path: "" } });
21 |
22 | // Render the controls element.
23 | render(
24 | () => (
25 |
26 |
27 |
28 | ),
29 | this,
30 | );
31 | }
32 |
33 | attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
34 | if (name === "url") {
35 | this.lib.connection.url.set(newValue ? new URL(newValue) : undefined);
36 | } else if (name === "device") {
37 | this.lib.broadcast.device.set(newValue as Device);
38 | } else if (name === "audio") {
39 | this.lib.broadcast.audio.constraints.set(newValue !== null);
40 | } else if (name === "video") {
41 | this.lib.broadcast.video.constraints.set(newValue !== null);
42 | } else if (name === "controls") {
43 | this.#controls.set(newValue !== null);
44 | }
45 | }
46 | }
47 |
48 | customElements.define("hang-publish", HangPublish);
49 |
50 | declare global {
51 | interface HTMLElementTagNameMap {
52 | "hang-publish": HangPublish;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/js/hang/src/publish/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./audio";
2 | export * from "./broadcast";
3 | export * from "./video";
4 | export * from "./publish";
5 | export * from "./controls";
6 |
7 | // NOTE: element is not exported from this module
8 | // You have to import it from @kixelated/hang/publish/element instead.
9 |
--------------------------------------------------------------------------------
/js/hang/src/publish/publish.ts:
--------------------------------------------------------------------------------
1 | import { Signal, Signals, signal } from "@kixelated/signals";
2 | import { Connection, ConnectionProps } from "../connection";
3 | import { Broadcast, BroadcastProps } from "./broadcast";
4 |
5 | export interface PublishProps {
6 | connection?: ConnectionProps;
7 | broadcast?: BroadcastProps;
8 | preview?: HTMLVideoElement;
9 | }
10 |
11 | export class Publish {
12 | connection: Connection;
13 | broadcast: Broadcast;
14 | preview: Signal;
15 |
16 | #signals = new Signals();
17 |
18 | constructor(props?: PublishProps) {
19 | this.connection = new Connection(props?.connection);
20 | this.broadcast = new Broadcast(this.connection, props?.broadcast);
21 | this.preview = signal(props?.preview);
22 |
23 | this.#signals.effect(() => {
24 | const media = this.broadcast.video.media.get();
25 | const preview = this.preview.get();
26 | if (!preview || !media) return;
27 |
28 | preview.srcObject = new MediaStream([media]) ?? null;
29 | return () => {
30 | preview.srcObject = null;
31 | };
32 | });
33 |
34 | // Only publish when we have media available.
35 | this.#signals.effect(() => {
36 | const audio = this.broadcast.audio.media.get();
37 | const video = this.broadcast.video.media.get();
38 | this.broadcast.publish.set(!!audio || !!video);
39 | });
40 | }
41 |
42 | close() {
43 | this.#signals.close();
44 | this.broadcast.close();
45 | this.connection.close();
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/js/hang/src/support/element.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from "solid-js";
2 | import { render } from "solid-js/web";
3 | import { Modal, Partial, SupportRole } from "./";
4 |
5 | export default class HangSupport extends HTMLElement {
6 | #role = createSignal("all");
7 | #show = createSignal("full");
8 |
9 | static get observedAttributes() {
10 | return ["role", "show"];
11 | }
12 |
13 | attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
14 | if (name === "role") {
15 | const role = newValue ?? "all";
16 |
17 | if (role === "core" || role === "watch" || role === "publish" || role === "all") {
18 | this.#role[1](role);
19 | } else {
20 | throw new Error(`Invalid role: ${role}`);
21 | }
22 | } else if (name === "show") {
23 | const show = newValue ?? "full";
24 | if (show === "full" || show === "partial" || show === "none") {
25 | this.#show[1](show);
26 | } else {
27 | throw new Error(`Invalid show: ${show}`);
28 | }
29 | }
30 | }
31 |
32 | connectedCallback() {
33 | const root = this.appendChild(document.createElement("div"));
34 | render(() => , root);
35 | }
36 | }
37 |
38 | customElements.define("hang-support", HangSupport);
39 |
40 | declare global {
41 | interface HTMLElementTagNameMap {
42 | "hang-support": HangSupport;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/js/hang/src/watch/controls.tsx:
--------------------------------------------------------------------------------
1 | import { Match, Show, Switch } from "solid-js";
2 | import { JSX } from "solid-js/jsx-runtime";
3 | import { AudioEmitter } from "./audio";
4 | import { Broadcast } from "./broadcast";
5 | import { VideoRenderer } from "./video";
6 |
7 | // A simple set of controls mostly for the demo.
8 | // You don't have to use SolidJS to implement your own controls; use the .subscribe() API instead.
9 | export function Controls(props: {
10 | broadcast: Broadcast;
11 | video: VideoRenderer;
12 | audio: AudioEmitter;
13 | root: HTMLElement;
14 | }): JSX.Element {
15 | const root = props.root;
16 |
17 | return (
18 |
32 | );
33 | }
34 |
35 | function Pause(props: { video: VideoRenderer }): JSX.Element {
36 | const togglePause = (e: MouseEvent) => {
37 | e.preventDefault();
38 | props.video.paused.set((prev) => !prev);
39 | };
40 |
41 | return (
42 |
43 | ⏸️>}>
44 | ▶️
45 |
46 |
47 | );
48 | }
49 |
50 | function Volume(props: { audio: AudioEmitter }): JSX.Element {
51 | const volume = props.audio.volume;
52 | const muted = props.audio.muted;
53 |
54 | const changeVolume = (str: string) => {
55 | const v = Number.parseFloat(str) / 100;
56 | volume.set(v);
57 | };
58 |
59 | const toggleMute = () => {
60 | muted.set((p) => !p);
61 | };
62 |
63 | const rounded = () => Math.round(volume.get() * 100);
64 |
65 | return (
66 |
67 |
68 | 🔊>}>
69 | 🔇
70 |
71 |
72 | changeVolume(e.currentTarget.value)}
78 | />
79 | {rounded()}%
80 |
81 | );
82 | }
83 |
84 | function Status(props: { broadcast: Broadcast }): JSX.Element {
85 | const url = props.broadcast.connection.url.get;
86 | const connection = props.broadcast.connection.status.get;
87 | const broadcast = props.broadcast.status.get;
88 |
89 | return (
90 |
91 |
92 | 🔴 No URL
93 | 🔴 Disconnected
94 | 🟡 Connecting...
95 | 🔴 Offline
96 | 🟡 Loading...
97 | 🟢 Live
98 | 🟢 Connected
99 |
100 |
101 | );
102 | }
103 |
104 | function Fullscreen(props: { root: HTMLElement }): JSX.Element {
105 | const toggleFullscreen = () => {
106 | if (document.fullscreenElement) {
107 | document.exitFullscreen();
108 | } else {
109 | props.root.requestFullscreen();
110 | }
111 | };
112 | return (
113 |
114 | ⛶
115 |
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/js/hang/src/watch/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./audio";
2 | export * from "./broadcast";
3 | export * from "./video";
4 | export * from "./controls";
5 |
6 | // NOTE: element is not exported from this module
7 | // You have to import it from @kixelated/hang/watch/element instead.
8 |
--------------------------------------------------------------------------------
/js/hang/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules", "dist"],
5 | "compilerOptions": {
6 | "outDir": "dist",
7 | "jsx": "react-jsx",
8 | "jsxImportSource": "solid-js"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/js/justfile:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env just --justfile
2 |
3 | # Using Just: https://github.com/casey/just?tab=readme-ov-file#installation
4 |
5 | # List all of the available commands.
6 | default:
7 | just --list
8 |
9 | # Run the web server
10 | web:
11 | pnpm -r i
12 | pnpm -r run dev
13 |
14 | # Run the CI checks
15 | check flags="":
16 | pnpm -r install {{flags}}
17 |
18 | # Make sure Typescript compiles
19 | pnpm -r run check
20 |
21 | # Run the JS tests via node.
22 | pnpm -r test
23 |
24 | # Format/lint the JS packages
25 | pnpm -r exec biome check
26 |
27 | # Make sure the JS packages are not vulnerable
28 | pnpm -r exec pnpm audit
29 |
30 | # TODO: Check for unused imports (fix the false positives)
31 | # pnpm exec knip --no-exit-code
32 |
33 | # Automatically fix some issues.
34 | fix flags="":
35 | # Fix the JS packages
36 | pnpm -r install {{flags}}
37 |
38 | # Format and lint
39 | pnpm -r exec biome check --fix
40 |
41 | # Make sure the JS packages are not vulnerable
42 | pnpm -r exec pnpm audit --fix
43 |
44 | # Upgrade any tooling
45 | upgrade:
46 | # Update the NPM dependencies
47 | pnpm self-update
48 | pnpm -r update
49 | pnpm -r outdated
50 |
51 | # Build the packages
52 | build flags="":
53 | pnpm -r install {{flags}}
54 | pnpm -r run build
55 |
56 | # Generate documentation
57 | doc: build
58 | # Currently bugged for hang
59 | pnpm --dir moq exec api-extractor run --local --verbose
60 | pnpm exec api-documenter markdown --input api --output doc
61 |
--------------------------------------------------------------------------------
/js/moq/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Media over QUIC (MoQ) is a live (media) delivery protocol utilizing QUIC.
6 | It utilizes new browser technologies such as [WebTransport](https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API) and [WebCodecs](https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API) to provide WebRTC-like functionality.
7 | Despite the focus on media, the transport is generic and designed to scale to enormous viewership via clustered relay servers (aka a CDN).
8 | See [quic.video](https://quic.video) for more information.
9 |
10 | **Note:** this project is a [fork](https://quic.video/blog/transfork) of the [IETF specification](https://datatracker.ietf.org/group/moq/documents/).
11 | The principles are the same but the implementation is exponentially simpler given a narrower focus (and no politics).
12 |
13 | # Usage
14 | This library contains just the generic transport.
15 | More documentation will be available later, until then refer to the code.
16 |
17 | ```ts
18 | import * as Moq from "@kixelated/moq";
19 |
20 | const conn = await Moq.Connection.connect(new URL("http://localhost:4443/"));
21 |
22 | // Optional: Discover broadcasts matching a prefix.
23 | const announced = conn.announced("demo/");
24 | for (;;) {
25 | const announce = await announced.next();
26 | if (!announce) break;
27 |
28 | console.log("discovered broadcast:", announce.path);
29 |
30 | // NOTE: This code is untested and the API is subject to change.
31 | const broadcast = conn.consume(`demo/${announce.path}`);
32 | const track = broadcast.subscribe("catalog.json");
33 |
34 | // A track can have multiple groups (unordered+unreliable).
35 | const group = await track.nextGroup();
36 |
37 | // A group can have multiple frames (ordered+reliable).
38 | const frame = await group.nextFrame();
39 |
40 | // This is a Uint8Array; but the contents are actually JSON.
41 | console.log("received frame:", frame);
42 |
43 | // Don't forget to close to release resources.
44 | frame.close();
45 | group.close();
46 | track.close();
47 | broadcast.close();
48 | }
49 | ```
50 |
51 |
52 | # License
53 |
54 | Licensed under either:
55 |
56 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
57 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
58 |
--------------------------------------------------------------------------------
/js/moq/api-extractor.json:
--------------------------------------------------------------------------------
1 | /**
2 | * Config file for API Extractor. For more info, please visit: https://api-extractor.com
3 | */
4 | {
5 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
6 | "extends": "../api-extractor.json",
7 | "mainEntryPointFilePath": "dist/index.d.ts"
8 | }
9 |
--------------------------------------------------------------------------------
/js/moq/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@kixelated/moq",
3 | "type": "module",
4 | "version": "0.5.0",
5 | "description": "Media over QUIC library",
6 | "license": "(MIT OR Apache-2.0)",
7 | "repository": "github:kixelated/moq",
8 | "files": ["./src", "README.md"],
9 | "exports": {
10 | ".": "./src/index.js"
11 | },
12 | "scripts": {
13 | "build": "tsc -b",
14 | "check": "tsc --noEmit",
15 | "test": "tsx --test",
16 | "release": "tsx scripts/release.ts"
17 | },
18 | "devDependencies": {
19 | "typescript": "^5.8.3",
20 | "@typescript/lib-dom": "npm:@types/web@^0.0.235",
21 | "tsx": "^4.19.4",
22 | "vite": "^6.3.5",
23 | "vite-plugin-html": "^3.2.2"
24 | },
25 | "dependencies": {
26 | "buffer": "^6.0.3"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/js/moq/scripts/release.ts:
--------------------------------------------------------------------------------
1 | // ChatGPT made a script that rewrites package.json file to use the correct paths.
2 | // It's not pretty but nothing in NPM is.
3 |
4 | import { execSync } from "node:child_process";
5 | import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6 | import { join } from "node:path";
7 |
8 | console.log("🧹 Cleaning dist/...");
9 | rmSync("dist", { recursive: true, force: true });
10 |
11 | console.log("🛠️ Building...");
12 | execSync("pnpm i && pnpm build", { stdio: "inherit" });
13 |
14 | console.log("✍️ Rewriting package.json...");
15 | const pkg = JSON.parse(readFileSync("package.json", "utf8"));
16 |
17 | function rewritePath(p: string): string {
18 | return p.replace(/^\.\/src/, ".").replace(/\.ts(x)?$/, ".js");
19 | }
20 |
21 | pkg.main &&= rewritePath(pkg.main);
22 | pkg.types &&= rewritePath(pkg.types);
23 |
24 | if (pkg.exports) {
25 | for (const key in pkg.exports) {
26 | const val = pkg.exports[key];
27 | if (typeof val === "string") {
28 | pkg.exports[key] = rewritePath(val);
29 | } else if (typeof val === "object") {
30 | for (const sub in val) {
31 | if (typeof val[sub] === "string") {
32 | val[sub] = rewritePath(val[sub]);
33 | }
34 | }
35 | }
36 | }
37 | }
38 |
39 | if (pkg.sideEffects) {
40 | pkg.sideEffects = pkg.sideEffects.map(rewritePath);
41 | }
42 |
43 | if (pkg.files) {
44 | pkg.files = pkg.files.map(rewritePath);
45 | }
46 |
47 | // biome-ignore lint/performance/noDelete:
48 | delete pkg.devDependencies;
49 | // biome-ignore lint/performance/noDelete:
50 | delete pkg.scripts;
51 |
52 | console.log(pkg);
53 |
54 | mkdirSync("dist", { recursive: true });
55 | writeFileSync("dist/package.json", JSON.stringify(pkg, null, 2));
56 |
57 | // Copy static files
58 | console.log("📄 Copying README.md...");
59 | copyFileSync("README.md", join("dist", "README.md"));
60 |
61 | console.log("🔍 Installing dependencies...");
62 | execSync("pnpm install", {
63 | stdio: "inherit",
64 | cwd: "dist",
65 | });
66 |
67 | console.log("🚀 Publishing...");
68 | execSync("pnpm publish --access=public", {
69 | stdio: "inherit",
70 | cwd: "dist",
71 | });
72 |
--------------------------------------------------------------------------------
/js/moq/src/announced.ts:
--------------------------------------------------------------------------------
1 | import { WatchConsumer, WatchProducer } from "./util/watch";
2 |
3 | /**
4 | * The availability of a broadcast.
5 | *
6 | * @public
7 | */
8 | export interface Announce {
9 | path: string;
10 | active: boolean;
11 | }
12 |
13 | /**
14 | * Handles writing announcements to the announcement queue.
15 | *
16 | * @public
17 | */
18 | export class AnnouncedProducer {
19 | #queue = new WatchProducer([]);
20 |
21 | /**
22 | * Writes an announcement to the queue.
23 | * @param announcement - The announcement to write
24 | */
25 | write(announcement: Announce) {
26 | this.#queue.update((announcements) => {
27 | announcements.push(announcement);
28 | return announcements;
29 | });
30 | }
31 |
32 | /**
33 | * Aborts the writer with an error.
34 | * @param reason - The error reason for aborting
35 | */
36 | abort(reason: Error) {
37 | this.#queue.abort(reason);
38 | }
39 |
40 | /**
41 | * Closes the writer.
42 | */
43 | close() {
44 | this.#queue.close();
45 | }
46 |
47 | /**
48 | * Returns a promise that resolves when the writer is closed.
49 | * @returns A promise that resolves when closed
50 | */
51 | async closed(): Promise {
52 | await this.#queue.closed();
53 | }
54 |
55 | /**
56 | * Creates a new AnnouncedConsumer that only returns the announcements for the specified prefix.
57 | * @param prefix - The prefix for the consumer
58 | * @returns A new AnnouncedConsumer instance
59 | */
60 | consume(prefix = ""): AnnouncedConsumer {
61 | return new AnnouncedConsumer(this.#queue.consume(), prefix);
62 | }
63 | }
64 |
65 | /**
66 | * Handles reading announcements from the announcement queue.
67 | *
68 | * @public
69 | */
70 | export class AnnouncedConsumer {
71 | /** The prefix for this reader */
72 | readonly prefix: string;
73 |
74 | #queue: WatchConsumer;
75 | #index = 0;
76 |
77 | /**
78 | * Creates a new AnnounceConsumer with the specified prefix and queue.
79 | * @param prefix - The prefix for the reader
80 | * @param queue - The queue to read announcements from
81 | *
82 | * @internal
83 | */
84 | constructor(queue: WatchConsumer, prefix = "") {
85 | this.#queue = queue;
86 | this.prefix = prefix;
87 | }
88 |
89 | /**
90 | * Returns the next announcement from the queue.
91 | * @returns A promise that resolves to the next announcement or undefined
92 | */
93 | async next(): Promise {
94 | for (;;) {
95 | const queue = await this.#queue.when((v) => v.length > this.#index);
96 | if (!queue) return undefined;
97 |
98 | while (this.#index < queue.length) {
99 | const announce = queue.at(this.#index++);
100 | if (!announce?.path.startsWith(this.prefix)) continue;
101 |
102 | // We have to remove the prefix so we only return our suffix.
103 | return {
104 | path: announce.path.slice(this.prefix.length),
105 | active: announce.active,
106 | };
107 | }
108 | }
109 | }
110 |
111 | /**
112 | * Closes the reader.
113 | */
114 | close() {
115 | this.#queue.close();
116 | }
117 |
118 | /**
119 | * Returns a promise that resolves when the reader is closed.
120 | * @returns A promise that resolves when closed
121 | */
122 | async closed(): Promise {
123 | await this.#queue.closed();
124 | }
125 |
126 | /**
127 | * Creates a new instance of the reader using the same queue and prefix.
128 | *
129 | * @returns A new AnnounceConsumer instance
130 | */
131 | clone(): AnnouncedConsumer {
132 | return new AnnouncedConsumer(this.#queue.clone(), this.prefix);
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/js/moq/src/group.ts:
--------------------------------------------------------------------------------
1 | import { WatchConsumer, WatchProducer } from "./util/watch";
2 |
3 | /**
4 | * Handles writing frames to a group.
5 | *
6 | * @public
7 | */
8 | export class GroupProducer {
9 | /** The unique identifier for this writer */
10 | readonly id: number;
11 |
12 | // A stream of frames.
13 | #frames = new WatchProducer([]);
14 |
15 | /**
16 | * Creates a new GroupProducer with the specified ID and frames producer.
17 | * @param id - The unique identifier
18 | *
19 | * @internal
20 | */
21 | constructor(id: number) {
22 | this.id = id;
23 | }
24 |
25 | /**
26 | * Writes a frame to the group.
27 | * @param frame - The frame to write
28 | */
29 | writeFrame(frame: Uint8Array) {
30 | this.#frames.update((frames) => [...frames, frame]);
31 | }
32 |
33 | /**
34 | * Closes the writer.
35 | */
36 | close() {
37 | this.#frames.close();
38 | }
39 |
40 | /**
41 | * Returns a promise that resolves when the writer is unused.
42 | * @returns A promise that resolves when unused
43 | */
44 | async unused(): Promise {
45 | await this.#frames.unused();
46 | }
47 |
48 | /**
49 | * Aborts the writer with an error.
50 | * @param reason - The error reason for aborting
51 | */
52 | abort(reason: Error) {
53 | this.#frames.abort(reason);
54 | }
55 |
56 | consume(): GroupConsumer {
57 | return new GroupConsumer(this.#frames.consume(), this.id);
58 | }
59 | }
60 |
61 | /**
62 | * Handles reading frames from a group.
63 | *
64 | * @public
65 | */
66 | export class GroupConsumer {
67 | /** The unique identifier for this reader */
68 | readonly id: number;
69 |
70 | #frames: WatchConsumer;
71 | #index = 0;
72 |
73 | /**
74 | * Creates a new GroupConsumer with the specified ID and frames consumer.
75 | * @param id - The unique identifier
76 | * @param frames - The frames consumer
77 | *
78 | * @internal
79 | */
80 | constructor(frames: WatchConsumer, id: number) {
81 | this.id = id;
82 | this.#frames = frames;
83 | }
84 |
85 | /**
86 | * Reads the next frame from the group.
87 | * @returns A promise that resolves to the next frame or undefined
88 | */
89 | async readFrame(): Promise {
90 | const frames = await this.#frames.when((frames) => frames.length > this.#index);
91 | return frames?.at(this.#index++);
92 | }
93 |
94 | /**
95 | * Closes the reader.
96 | */
97 | close() {
98 | this.#frames.close();
99 | }
100 |
101 | /**
102 | * Creates a new instance of the reader using the same frames consumer.
103 | * @returns A new GroupConsumer instance
104 | */
105 | clone(): GroupConsumer {
106 | return new GroupConsumer(this.#frames.clone(), this.id);
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/js/moq/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./announced";
2 | export * from "./broadcast";
3 | export * from "./connection";
4 | export * from "./group";
5 | export * from "./track";
6 |
--------------------------------------------------------------------------------
/js/moq/src/util/error.ts:
--------------------------------------------------------------------------------
1 | // I hate javascript.
2 | export function error(err: unknown): Error {
3 | return err instanceof Error ? err : new Error(String(err));
4 | }
5 |
--------------------------------------------------------------------------------
/js/moq/src/util/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./error";
2 | export * from "./watch";
3 |
--------------------------------------------------------------------------------
/js/moq/src/wire/announce.ts:
--------------------------------------------------------------------------------
1 | import type { Reader, Writer } from "./stream";
2 |
3 | export class Announce {
4 | suffix: string;
5 | active: boolean;
6 |
7 | constructor(suffix: string, active: boolean) {
8 | this.suffix = suffix;
9 | this.active = active;
10 | }
11 |
12 | async encode(w: Writer) {
13 | await w.u53(this.active ? 1 : 0);
14 | await w.string(this.suffix);
15 | }
16 |
17 | static async decode(r: Reader): Promise {
18 | const active = (await r.u53()) === 1;
19 | const suffix = await r.string();
20 | return new Announce(suffix, active);
21 | }
22 |
23 | static async decode_maybe(r: Reader): Promise {
24 | if (await r.done()) return;
25 | return await Announce.decode(r);
26 | }
27 | }
28 |
29 | export class AnnounceInterest {
30 | static StreamID = 0x1;
31 | prefix: string;
32 |
33 | constructor(prefix: string) {
34 | this.prefix = prefix;
35 | }
36 |
37 | async encode(w: Writer) {
38 | await w.string(this.prefix);
39 | }
40 |
41 | static async decode(r: Reader): Promise {
42 | const prefix = await r.string();
43 | return new AnnounceInterest(prefix);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/js/moq/src/wire/group.ts:
--------------------------------------------------------------------------------
1 | import type { Reader, Writer } from "./stream";
2 |
3 | export class Group {
4 | subscribe: bigint;
5 | sequence: number;
6 |
7 | static StreamID = 0x0;
8 |
9 | constructor(subscribe: bigint, sequence: number) {
10 | this.subscribe = subscribe;
11 | this.sequence = sequence;
12 | }
13 |
14 | async encode(w: Writer) {
15 | await w.u62(this.subscribe);
16 | await w.u53(this.sequence);
17 | }
18 |
19 | static async decode(r: Reader): Promise {
20 | return new Group(await r.u62(), await r.u53());
21 | }
22 | }
23 |
24 | export class GroupDrop {
25 | sequence: number;
26 | count: number;
27 | error: number;
28 |
29 | constructor(sequence: number, count: number, error: number) {
30 | this.sequence = sequence;
31 | this.count = count;
32 | this.error = error;
33 | }
34 |
35 | async encode(w: Writer) {
36 | await w.u53(this.sequence);
37 | await w.u53(this.count);
38 | await w.u53(this.error);
39 | }
40 |
41 | static async decode(r: Reader): Promise {
42 | return new GroupDrop(await r.u53(), await r.u53(), await r.u53());
43 | }
44 | }
45 |
46 | export class Frame {
47 | payload: Uint8Array;
48 |
49 | constructor(payload: Uint8Array) {
50 | this.payload = payload;
51 | }
52 |
53 | async encode(w: Writer) {
54 | await w.u53(this.payload.byteLength);
55 | await w.write(this.payload);
56 | }
57 |
58 | static async decode(r: Reader): Promise {
59 | const size = await r.u53();
60 | const payload = await r.read(size);
61 | return new Frame(payload);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/js/moq/src/wire/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./announce";
2 | export * from "./group";
3 | export * from "./session";
4 | export * from "./stream";
5 | export * from "./subscribe";
6 |
--------------------------------------------------------------------------------
/js/moq/src/wire/session.ts:
--------------------------------------------------------------------------------
1 | import type { Reader, Writer } from "./stream";
2 |
3 | export const Version = {
4 | DRAFT_00: 0xff000000,
5 | DRAFT_01: 0xff000001,
6 | DRAFT_02: 0xff000002,
7 | DRAFT_03: 0xff000003,
8 | FORK_00: 0xff0bad00,
9 | FORK_01: 0xff0bad01,
10 | FORK_02: 0xff0bad02,
11 | FORK_03: 0xff0bad03,
12 | FORK_04: 0xff0bad04,
13 | LITE_00: 0xff0dad00,
14 | } as const;
15 |
16 | export const CURRENT_VERSION = Version.LITE_00;
17 |
18 | export class Extensions {
19 | entries: Map;
20 |
21 | constructor() {
22 | this.entries = new Map();
23 | }
24 |
25 | set(id: bigint, value: Uint8Array) {
26 | this.entries.set(id, value);
27 | }
28 |
29 | get(id: bigint): Uint8Array | undefined {
30 | return this.entries.get(id);
31 | }
32 |
33 | remove(id: bigint): Uint8Array | undefined {
34 | const value = this.entries.get(id);
35 | this.entries.delete(id);
36 | return value;
37 | }
38 |
39 | async encode(w: Writer) {
40 | await w.u53(this.entries.size);
41 | for (const [id, value] of this.entries) {
42 | await w.u62(id);
43 | await w.u53(value.length);
44 | await w.write(value);
45 | }
46 | }
47 |
48 | static async decode(r: Reader): Promise {
49 | const count = await r.u53();
50 | const params = new Extensions();
51 |
52 | for (let i = 0; i < count; i++) {
53 | const id = await r.u62();
54 | const size = await r.u53();
55 | const value = await r.read(size);
56 |
57 | if (params.entries.has(id)) {
58 | throw new Error(`duplicate parameter id: ${id.toString()}`);
59 | }
60 |
61 | params.entries.set(id, value);
62 | }
63 |
64 | return params;
65 | }
66 | }
67 |
68 | export class SessionClient {
69 | versions: number[];
70 | extensions: Extensions;
71 |
72 | static StreamID = 0x0;
73 |
74 | constructor(versions: number[], extensions = new Extensions()) {
75 | this.versions = versions;
76 | this.extensions = extensions;
77 | }
78 |
79 | async encode(w: Writer) {
80 | await w.u53(this.versions.length);
81 | for (const v of this.versions) {
82 | await w.u53(v);
83 | }
84 |
85 | await this.extensions.encode(w);
86 | }
87 |
88 | static async decode(r: Reader): Promise {
89 | const versions: number[] = [];
90 | const count = await r.u53();
91 | for (let i = 0; i < count; i++) {
92 | versions.push(await r.u53());
93 | }
94 |
95 | const extensions = await Extensions.decode(r);
96 | return new SessionClient(versions, extensions);
97 | }
98 | }
99 |
100 | export class SessionServer {
101 | version: number;
102 | extensions: Extensions;
103 |
104 | constructor(version: number, extensions = new Extensions()) {
105 | this.version = version;
106 | this.extensions = extensions;
107 | }
108 |
109 | async encode(w: Writer) {
110 | await w.u53(this.version);
111 | await this.extensions.encode(w);
112 | }
113 |
114 | static async decode(r: Reader): Promise {
115 | const version = await r.u53();
116 | const extensions = await Extensions.decode(r);
117 | return new SessionServer(version, extensions);
118 | }
119 | }
120 |
121 | export class SessionInfo {
122 | bitrate: number;
123 |
124 | constructor(bitrate: number) {
125 | this.bitrate = bitrate;
126 | }
127 |
128 | async encode(w: Writer) {
129 | await w.u53(this.bitrate);
130 | }
131 |
132 | static async decode(r: Reader): Promise {
133 | const bitrate = await r.u53();
134 | return new SessionInfo(bitrate);
135 | }
136 |
137 | static async decode_maybe(r: Reader): Promise {
138 | if (await r.done()) return;
139 | return await SessionInfo.decode(r);
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/js/moq/src/wire/subscribe.ts:
--------------------------------------------------------------------------------
1 | import type { Reader, Writer } from "./stream";
2 |
3 | export class SubscribeUpdate {
4 | priority: number;
5 |
6 | constructor(priority: number) {
7 | this.priority = priority;
8 | }
9 |
10 | async encode(w: Writer) {
11 | await w.u53(this.priority);
12 | }
13 |
14 | static async decode(r: Reader): Promise {
15 | const priority = await r.u53();
16 | return new SubscribeUpdate(priority);
17 | }
18 |
19 | static async decode_maybe(r: Reader): Promise {
20 | if (await r.done()) return;
21 | return await SubscribeUpdate.decode(r);
22 | }
23 | }
24 |
25 | export class Subscribe extends SubscribeUpdate {
26 | id: bigint;
27 | broadcast: string;
28 | track: string;
29 |
30 | static StreamID = 0x2;
31 |
32 | constructor(id: bigint, broadcast: string, track: string, priority: number) {
33 | super(priority);
34 | this.id = id;
35 | this.broadcast = broadcast;
36 | this.track = track;
37 | }
38 |
39 | override async encode(w: Writer) {
40 | await w.u62(this.id);
41 | await w.string(this.broadcast);
42 | await w.string(this.track);
43 | await super.encode(w);
44 | }
45 |
46 | static override async decode(r: Reader): Promise {
47 | const id = await r.u62();
48 | const broadcast = await r.string();
49 | const track = await r.string();
50 | const update = await SubscribeUpdate.decode(r);
51 | return new Subscribe(id, broadcast, track, update.priority);
52 | }
53 | }
54 |
55 | export class SubscribeOk {
56 | priority: number;
57 |
58 | constructor(priority: number) {
59 | this.priority = priority;
60 | }
61 |
62 | async encode(w: Writer) {
63 | await w.u53(this.priority);
64 | }
65 |
66 | static async decode(r: Reader): Promise {
67 | const priority = await r.u53();
68 | return new SubscribeOk(priority);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/js/moq/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules", "dist"],
5 | "compilerOptions": {
6 | "outDir": "dist"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/js/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "moq",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "devDependencies": {
7 | "@biomejs/biome": "^1.9.4",
8 | "@microsoft/api-documenter": "^7.26.27",
9 | "@microsoft/api-extractor": "^7.52.8",
10 | "@types/node": "^22.15.21",
11 | "concurrently": "^9.1.2",
12 | "cpy-cli": "^5.0.0",
13 | "knip": "^5.57.1",
14 | "typescript": "^5.8.3"
15 | },
16 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
17 | }
18 |
--------------------------------------------------------------------------------
/js/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - moq
3 | - hang
4 | - hang-demo
5 | - signals
6 | onlyBuiltDependencies:
7 | - core-js
8 | - esbuild
9 | - wasm-pack
10 |
--------------------------------------------------------------------------------
/js/signals/README.md:
--------------------------------------------------------------------------------
1 | # Signals
2 | Just a wrapper around SolidJS signals.
--------------------------------------------------------------------------------
/js/signals/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@kixelated/signals",
3 | "type": "module",
4 | "version": "0.1.0",
5 | "description": "Wrapper around SolidJS signals",
6 | "license": "(MIT OR Apache-2.0)",
7 | "repository": "github:kixelated/moq",
8 | "exports": {
9 | ".": "./src/index.ts"
10 | },
11 | "files": ["./src"],
12 | "scripts": {
13 | "build": "tsc -b",
14 | "check": "tsc --noEmit",
15 | "release": "tsx scripts/release.ts"
16 | },
17 | "dependencies": {
18 | "solid-js": "^1.9.7"
19 | },
20 | "devDependencies": {
21 | "tsx": "^4.19.4",
22 | "typescript": "^5.8.3"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/js/signals/scripts/release.ts:
--------------------------------------------------------------------------------
1 | // ChatGPT made a script that rewrites package.json file to use the correct paths.
2 | // It's not pretty but nothing in NPM is.
3 |
4 | import { execSync } from "node:child_process";
5 | import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6 | import { join } from "node:path";
7 |
8 | console.log("🧹 Cleaning dist/...");
9 | rmSync("dist", { recursive: true, force: true });
10 |
11 | console.log("🛠️ Building...");
12 | execSync("pnpm i && pnpm build", { stdio: "inherit" });
13 |
14 | console.log("✍️ Rewriting package.json...");
15 | const pkg = JSON.parse(readFileSync("package.json", "utf8"));
16 |
17 | function rewritePath(p: string): string {
18 | return p.replace(/^\.\/src/, ".").replace(/\.ts(x)?$/, ".js");
19 | }
20 |
21 | pkg.main &&= rewritePath(pkg.main);
22 | pkg.types &&= rewritePath(pkg.types);
23 |
24 | if (pkg.exports) {
25 | for (const key in pkg.exports) {
26 | const val = pkg.exports[key];
27 | if (typeof val === "string") {
28 | pkg.exports[key] = rewritePath(val);
29 | } else if (typeof val === "object") {
30 | for (const sub in val) {
31 | if (typeof val[sub] === "string") {
32 | val[sub] = rewritePath(val[sub]);
33 | }
34 | }
35 | }
36 | }
37 | }
38 |
39 | if (pkg.sideEffects) {
40 | pkg.sideEffects = pkg.sideEffects.map(rewritePath);
41 | }
42 |
43 | if (pkg.files) {
44 | pkg.files = pkg.files.map(rewritePath);
45 | }
46 |
47 | // biome-ignore lint/performance/noDelete:
48 | delete pkg.devDependencies;
49 | // biome-ignore lint/performance/noDelete:
50 | delete pkg.scripts;
51 |
52 | console.log(pkg);
53 |
54 | mkdirSync("dist", { recursive: true });
55 | writeFileSync("dist/package.json", JSON.stringify(pkg, null, 2));
56 |
57 | // Copy static files
58 | console.log("📄 Copying README.md...");
59 | copyFileSync("README.md", join("dist", "README.md"));
60 |
61 | console.log("🔍 Installing dependencies...");
62 | execSync("pnpm install", {
63 | stdio: "inherit",
64 | cwd: "dist",
65 | });
66 |
67 | console.log("🚀 Publishing...");
68 | execSync("pnpm publish --access=public", {
69 | stdio: "inherit",
70 | cwd: "dist",
71 | });
72 |
--------------------------------------------------------------------------------
/js/signals/src/index.ts:
--------------------------------------------------------------------------------
1 | // A wrapper around solid-js signals to provide a more ergonomic API.
2 |
3 | import {
4 | Owner,
5 | SignalOptions,
6 | createEffect,
7 | createRoot,
8 | createSignal,
9 | getOwner,
10 | onCleanup,
11 | runWithOwner,
12 | untrack,
13 | } from "solid-js";
14 |
15 | export { batch } from "solid-js";
16 |
17 | export interface Signal extends Derived {
18 | set(value: T | ((prev: T) => T)): void;
19 | derived(fn: (value: T) => U): Derived;
20 | readonly(): Derived;
21 | }
22 |
23 | export function signal(initial: T, options?: SignalOptions): Signal {
24 | const [get, set] = createSignal(initial, options);
25 | return {
26 | get,
27 | set,
28 | peek: () => untrack(get),
29 | subscribe(fn) {
30 | const temp = new Signals();
31 | temp.effect(() => {
32 | fn(get());
33 | });
34 | return temp.close.bind(temp);
35 | },
36 | derived(fn) {
37 | return derived(() => fn(get()));
38 | },
39 | readonly() {
40 | return {
41 | get: () => get(),
42 | peek: () => untrack(get),
43 | subscribe(fn) {
44 | const temp = new Signals();
45 | temp.effect(() => {
46 | fn(get());
47 | });
48 | return temp.close.bind(temp);
49 | },
50 | };
51 | },
52 | };
53 | }
54 |
55 | export function cleanup(fn: () => void) {
56 | onCleanup(fn);
57 | }
58 |
59 | export type Dispose = () => void;
60 |
61 | // biome-ignore lint/suspicious/noConfusingVoidType: it's required to make the callback optional
62 | export type MaybeDispose = (() => void) | void;
63 |
64 | export interface Derived {
65 | get(): T;
66 | peek(): T;
67 | subscribe(fn: (value: T) => void): Dispose;
68 | }
69 |
70 | export function derived(fn: () => T, options?: SignalOptions): Derived {
71 | const sig = signal(fn(), options);
72 | effect(() => {
73 | sig.set(fn());
74 | });
75 |
76 | return sig;
77 | }
78 |
79 | export function effect(fn: () => MaybeDispose) {
80 | createEffect(() => {
81 | const res = fn();
82 | if (res) {
83 | onCleanup(res);
84 | }
85 | });
86 | }
87 |
88 | export class Signals {
89 | #dispose: Dispose;
90 | #owner: Owner;
91 |
92 | // @ts-ignore
93 | static dev = import.meta.env?.MODE !== "production";
94 |
95 | // Sanity check to make sure roots are being disposed on dev.
96 | static #finalizer = new FinalizationRegistry((debugInfo) => {
97 | console.warn(`Root was garbage collected without being closed:\n${debugInfo}`);
98 | });
99 |
100 | constructor() {
101 | if (Signals.dev) {
102 | const debug = new Error("created here:").stack ?? "No stack";
103 | Signals.#finalizer.register(this, debug, this);
104 | }
105 |
106 | [this.#dispose, this.#owner] = createRoot((dispose) => {
107 | const owner = getOwner();
108 | if (!owner) throw new Error("no owner");
109 |
110 | return [dispose, owner];
111 | });
112 | }
113 |
114 | effect(fn: () => MaybeDispose): void {
115 | const res = runWithOwner(this.#owner, () => {
116 | effect(fn);
117 | return true;
118 | });
119 | if (!res) {
120 | throw new Error("effect called after root was closed");
121 | }
122 | }
123 |
124 | derived(fn: () => T, options?: SignalOptions): Derived {
125 | const res = runWithOwner(this.#owner, () => derived(fn, options));
126 | if (!res) {
127 | throw new Error("derived called after root was closed");
128 | }
129 |
130 | return res;
131 | }
132 |
133 | cleanup(fn: () => void): void {
134 | const ok = runWithOwner(this.#owner, () => {
135 | onCleanup(fn);
136 | return true;
137 | });
138 |
139 | if (!ok) {
140 | fn();
141 | }
142 | }
143 |
144 | close(): void {
145 | this.#dispose();
146 | if (Signals.dev) {
147 | Signals.#finalizer.unregister(this);
148 | }
149 | }
150 | }
151 |
152 | export function signals(): Signals {
153 | return new Signals();
154 | }
155 |
--------------------------------------------------------------------------------
/js/signals/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules", "dist"],
5 | "compilerOptions": {
6 | "outDir": "dist"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/js/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["esnext", "dom"],
4 | "target": "esnext",
5 | "module": "esnext",
6 | "moduleResolution": "bundler",
7 |
8 | "declaration": true,
9 | "declarationMap": true,
10 | "isolatedModules": true,
11 | "sourceMap": true,
12 |
13 | // https://www.typescriptlang.org/tsconfig/#Type_Checking_6248
14 | "allowUnreachableCode": false,
15 | "allowUnusedLabels": false,
16 | "alwaysStrict": true,
17 | "exactOptionalPropertyTypes": false, // makes ? less usable
18 | "noFallthroughCasesInSwitch": true,
19 | "noImplicitAny": true,
20 | "noImplicitOverride": true,
21 | "noImplicitReturns": true,
22 | "noImplicitThis": true,
23 | "noPropertyAccessFromIndexSignature": true,
24 | "noUncheckedIndexedAccess": false, // Allows [0] access to arrays
25 | "noUnusedLocals": true,
26 | "noUnusedParameters": true,
27 | "strict": true,
28 | "strictBindCallApply": true,
29 | "strictBuiltinIteratorReturn": true,
30 | "strictFunctionTypes": true,
31 | "strictNullChecks": true,
32 | "strictPropertyInitialization": true,
33 | "useUnknownInCatchVariables": true,
34 |
35 | // Disable enums and weird Typescript features.
36 | "erasableSyntaxOnly": true,
37 |
38 | "skipLibCheck": true
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/justfile:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env just --justfile
2 |
3 | # Using Just: https://github.com/casey/just?tab=readme-ov-file#installation
4 |
5 | # These commands have been split into separate files for each language.
6 | # This is just a shim that uses the relevant file or calls both.
7 |
8 | set quiet
9 |
10 | # List all of the available commands.
11 | default:
12 | just --list
13 |
14 | # Install any required dependencies.
15 | setup:
16 | just --justfile rs/justfile setup
17 |
18 | # Run the relay, web server, and publish bbb.
19 | all: build
20 | # Then run the relay with a slight head start.
21 | # It doesn't matter if the web beats BBB because we support automatic reloading.
22 | js/node_modules/.bin/concurrently --kill-others --names srv,bbb,web --prefix-colors auto "just relay" "sleep 1 && just pub bbb" "sleep 1 && just web"
23 |
24 | # Run a localhost relay server
25 | relay:
26 | just --justfile rs/justfile relay
27 |
28 | # Publish a video using ffmpeg to the localhost relay server
29 | pub name:
30 | just --justfile rs/justfile pub {{name}}
31 |
32 | # Publish a video using gstreamer to the localhost relay server
33 | pub-gst name:
34 | just --justfile rs/justfile pub-gst {{name}}
35 |
36 | # Subscribe to a video using gstreamer
37 | sub-gst name:
38 | just --justfile rs/justfile sub-gst {{name}}
39 |
40 | # Publish a video using ffmpeg directly from hang to the localhost
41 | serve name:
42 | just --justfile rs/justfile serve {{name}}
43 |
44 | # Run the web server
45 | web:
46 | just --justfile js/justfile web
47 |
48 | # Publish the clock broadcast
49 | # `action` is either `publish` or `subscribe`
50 | clock action:
51 | just --justfile rs/justfile clock {{action}}
52 |
53 | # Run the CI checks
54 | check flags="":
55 | just --justfile rs/justfile check {{flags}}
56 | just --justfile js/justfile check
57 |
58 | # Automatically fix some issues.
59 | fix flags="":
60 | just --justfile rs/justfile fix {{flags}}
61 | just --justfile js/justfile fix
62 |
63 | # Upgrade any tooling
64 | upgrade:
65 | just --justfile rs/justfile upgrade
66 | just --justfile js/justfile upgrade
67 |
68 | # Build the packages
69 | build:
70 | just --justfile rs/justfile build
71 | just --justfile js/justfile build
72 |
--------------------------------------------------------------------------------
/rs/.cargo/config.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | rustflags = ["--cfg=web_sys_unstable_apis"]
3 |
--------------------------------------------------------------------------------
/rs/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | target
3 | dist
4 | dev
5 | node_modules
6 | pkg
7 |
--------------------------------------------------------------------------------
/rs/.release-plz.toml:
--------------------------------------------------------------------------------
1 | [workspace]
2 | dependencies_update = true
3 |
--------------------------------------------------------------------------------
/rs/.rustfmt.toml:
--------------------------------------------------------------------------------
1 | # i die on this hill
2 | hard_tabs = true
3 |
4 | max_width = 120
5 |
--------------------------------------------------------------------------------
/rs/Cargo.toml:
--------------------------------------------------------------------------------
1 | [workspace]
2 | members = [
3 | "hang",
4 | "hang-cli",
5 | "hang-gst",
6 | "hang-wasm",
7 | "moq",
8 | "moq-clock",
9 | "moq-native",
10 | "moq-relay",
11 | ]
12 | # without hang-gst because it requires gstreamer to be installed
13 | # and without hang-wasm because it's deprecated and weird
14 | default-members = [
15 | "hang",
16 | "hang-cli",
17 | "moq",
18 | "moq-clock",
19 | "moq-native",
20 | "moq-relay",
21 | ]
22 | resolver = "2"
23 |
24 | [workspace.dependencies]
25 | web-transport = "0.9.2"
26 | web-async = { version = "0.1.1", features = ["tracing"] }
27 | tokio = "1.45"
28 | serde = { version = "1", features = ["derive"] }
29 |
30 | hang = { version = "0.2", path = "hang" }
31 | moq-lite = { version = "0.2", path = "moq" }
32 | moq-native = { version = "0.6", path = "moq-native" }
33 |
34 | [profile.release.package.hang-wasm]
35 | # Tell `rustc` to optimize for small code size.
36 | opt-level = "s"
37 |
--------------------------------------------------------------------------------
/rs/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ubuntu:24.04 AS build
2 |
3 | WORKDIR /build
4 | ENV DEBIAN_FRONTEND=noninteractive
5 | ENV RUSTFLAGS=--cfg=web_sys_unstable_apis
6 |
7 | # Install base tools and GStreamer
8 | RUN apt-get update && \
9 | apt-get install -y \
10 | build-essential \
11 | curl \
12 | sudo \
13 | pkg-config \
14 | libssl-dev \
15 | libclang-dev \
16 | cmake \
17 | libgstreamer1.0-dev \
18 | libgstreamer-plugins-base1.0-dev \
19 | libgstreamer-plugins-bad1.0-dev \
20 | gstreamer1.0-plugins-base \
21 | gstreamer1.0-plugins-good \
22 | gstreamer1.0-plugins-bad \
23 | gstreamer1.0-plugins-ugly \
24 | gstreamer1.0-libav \
25 | gstreamer1.0-tools \
26 | gstreamer1.0-x \
27 | gstreamer1.0-alsa \
28 | gstreamer1.0-gl \
29 | gstreamer1.0-gtk3 \
30 | gstreamer1.0-qt5 \
31 | gstreamer1.0-pulseaudio \
32 | git \
33 | unzip \
34 | just \
35 | && apt-get clean
36 |
37 | # Install rustup + toolchain
38 | RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
39 | ENV PATH="/root/.cargo/bin:$PATH"
40 |
41 | # Add wasm target + clippy + rustfmt
42 | RUN rustup target add wasm32-unknown-unknown && \
43 | rustup component add clippy rustfmt
44 |
45 | # Let just do the rest.
46 | COPY justfile .
47 |
48 | # Install dependencies.
49 | # I'm not sure if all of these --mount flags are needed any longer.
50 | RUN --mount=type=cache,target=/usr/local/cargo/registry \
51 | --mount=type=cache,target=/build/target \
52 | just setup
53 |
54 | # Copy over the rest of the files.
55 | COPY . .
56 |
57 | # Make sure u guys don't write bad code before we build.
58 | RUN --mount=type=cache,target=/usr/local/cargo/registry \
59 | --mount=type=cache,target=/build/target \
60 | just check --release
61 |
62 | # Reuse a cache between builds.
63 | # I tried to `cargo install`, but it doesn't seem to work with workspaces.
64 | # There's also issues with the cache mount since it builds into /usr/local/cargo/bin
65 | # We can't mount that without clobbering cargo itself.
66 | # We instead we build the binaries and copy them to the cargo bin directory.
67 | RUN --mount=type=cache,target=/usr/local/cargo/registry \
68 | --mount=type=cache,target=/build/target \
69 | mkdir -p /out && \
70 | cargo build --release && \
71 | cp /build/target/release/moq* /out && \
72 | cp /build/target/release/hang* /out
73 |
74 | # moq-clock
75 | FROM ubuntu:24.04 AS moq-clock
76 | COPY --from=build /out/moq-clock /usr/local/bin
77 | ENTRYPOINT ["moq-clock"]
78 |
79 | ## hang (and hang-bbb)
80 | FROM ubuntu:24.04 AS hang
81 | RUN apt-get update && apt-get install -y ffmpeg wget
82 | COPY ./hang-bbb /usr/local/bin/hang-bbb
83 | COPY --from=build /out/hang /usr/local/bin
84 | ENTRYPOINT ["hang"]
85 |
86 | ## moq-relay
87 | FROM ubuntu:24.04 AS moq-relay
88 | COPY --from=build /out/moq-relay /usr/local/bin
89 | ENTRYPOINT ["moq-relay"]
90 |
--------------------------------------------------------------------------------
/rs/hang-bbb:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -euo pipefail
3 |
4 | # TODO Move to quic.video
5 |
6 | URL=${URL:-"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"}
7 | REGION=${REGION:-"now"}
8 |
9 | # Download the funny bunny
10 | wget -nv "${URL}" -O "tmp.mp4"
11 |
12 | # Properly fragment the file
13 | ffmpeg -y -i tmp.mp4 \
14 | -c copy \
15 | -f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
16 | "fragmented.mp4"
17 |
18 | rm tmp.mp4
19 |
20 | # ffmpeg
21 | # -hide_banner: Hide the banner
22 | # -v quiet: and any other output
23 | # -stats: But we still want some stats on stderr
24 | # -stream_loop -1: Loop the broadcast an infinite number of times
25 | # -re: Output in real-time
26 | # -i "${INPUT}": Read from a file on disk
27 | # -vf "drawtext": Render the current time in the corner of the video
28 | # -an: Disable audio for now
29 | # -b:v 3M: Output video at 3Mbps
30 | # -preset ultrafast: Don't use much CPU at the cost of quality
31 | # -tune zerolatency: Optimize for latency at the cost of quality
32 | # -f mp4: Output to mp4 format
33 | # -movflags: Build a fMP4 file with a frame per fragment
34 | # - | moq-pub: Output to stdout and moq-pub to publish
35 |
36 | # Run ffmpeg
37 | ffmpeg \
38 | -stream_loop -1 \
39 | -hide_banner \
40 | -v quiet \
41 | -re \
42 | -i "fragmented.mp4" \
43 | -vf "drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf:text='${REGION}\: %{gmtime\: %H\\\\\:%M\\\\\:%S.%3N}':x=(W-tw)-24:y=24:fontsize=48:fontcolor=white:box=1:boxcolor=black@0.5" \
44 | -an \
45 | -b:v 3M \
46 | -preset ultrafast \
47 | -tune zerolatency \
48 | -f mp4 \
49 | -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
50 | - | hang "$@"
--------------------------------------------------------------------------------
/rs/hang-cli/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.1.0](https://github.com/kixelated/moq/releases/tag/hang-cli-v0.1.0) - 2025-05-21
11 |
12 | ### Other
13 |
14 | - Split into Rust/Javascript halves and rebrand as moq-lite/hang ([#376](https://github.com/kixelated/moq/pull/376))
15 |
--------------------------------------------------------------------------------
/rs/hang-cli/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "hang-cli"
3 | description = "Media over QUIC"
4 | authors = ["Luke Curley "]
5 | repository = "https://github.com/kixelated/moq"
6 | license = "MIT OR Apache-2.0"
7 |
8 | version = "0.1.0"
9 | edition = "2021"
10 |
11 | keywords = ["quic", "http3", "webtransport", "media", "live"]
12 | categories = ["multimedia", "network-programming", "web-programming"]
13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
14 |
15 | [dependencies]
16 | anyhow = { version = "1", features = ["backtrace"] }
17 | axum = { version = "0.8", features = ["tokio"] }
18 | clap = { version = "4", features = ["derive"] }
19 | hang = { workspace = true }
20 | hyper-serve = { version = "0.6", features = ["tls-rustls"] }
21 | moq-native = { workspace = true }
22 | tokio = { workspace = true, features = ["full"] }
23 | tower-http = { version = "0.6", features = ["cors", "fs"] }
24 | tracing = "0.1"
25 | url = "2"
26 |
27 | [[bin]]
28 | name = "hang"
29 | path = "src/main.rs"
30 |
--------------------------------------------------------------------------------
/rs/hang-cli/src/client.rs:
--------------------------------------------------------------------------------
1 | use anyhow::Context;
2 | use clap::Args;
3 | use hang::cmaf::Import;
4 | use hang::moq_lite;
5 | use hang::{BroadcastConsumer, BroadcastProducer};
6 | use moq_lite::Session;
7 | use moq_native::quic;
8 | use tokio::io::AsyncRead;
9 | use url::Url;
10 |
11 | use super::Config;
12 |
13 | /// Publish a video stream to the provided URL.
14 | #[derive(Args, Clone)]
15 | pub struct ClientConfig {
16 | /// The URL of the MoQ server.
17 | ///
18 | /// The URL must start with `https://` or `http://`.
19 | /// - If `http` is used, a HTTP fetch to "/certificate.sha256" is first made to get the TLS certificiate fingerprint (insecure).
20 | /// The URL is then upgraded to `https`.
21 | ///
22 | /// - If `https` is used, then A WebTransport connection is made via QUIC to the provided host/port.
23 | /// The path is used to identify the broadcast, with the rest of the URL (ex. query/fragment) currently ignored.
24 | url: Url,
25 | }
26 |
27 | pub struct Client {
28 | config: Config,
29 | url: Url,
30 | }
31 |
32 | impl Client {
33 | pub fn new(config: Config, client_config: ClientConfig) -> Self {
34 | Self {
35 | config,
36 | url: client_config.url,
37 | }
38 | }
39 |
40 | pub async fn run(self, input: &mut T) -> anyhow::Result<()> {
41 | let producer = BroadcastProducer::new();
42 | let consumer = producer.consume();
43 |
44 | // Connect to the remote and start parsing stdin in parallel.
45 | tokio::select! {
46 | res = self.connect(consumer) => res,
47 | res = self.publish(producer, input) => res,
48 | }
49 | }
50 |
51 | async fn connect(&self, consumer: BroadcastConsumer) -> anyhow::Result<()> {
52 | let tls = self.config.tls.load()?;
53 | let quic = quic::Endpoint::new(quic::Config {
54 | bind: self.config.bind,
55 | tls,
56 | })?;
57 |
58 | tracing::info!(url = %self.url, "connecting");
59 |
60 | let session = quic.client.connect(self.url.clone()).await?;
61 | let mut session = Session::connect(session).await?;
62 |
63 | // The path is relative to the URL, so it's empty because we only publish one broadcast.
64 | session.publish("", consumer.inner.clone());
65 |
66 | tokio::select! {
67 | // On ctrl-c, close the session and exit.
68 | _ = tokio::signal::ctrl_c() => {
69 | session.close(moq_lite::Error::Cancel);
70 |
71 | // Give it a chance to close.
72 | tokio::time::sleep(std::time::Duration::from_millis(100)).await;
73 |
74 | Ok(())
75 | }
76 | // Otherwise wait for the session to close.
77 | _ = session.closed() => Err(session.closed().await.into()),
78 | }
79 | }
80 |
81 | async fn publish(&self, producer: BroadcastProducer, input: &mut T) -> anyhow::Result<()> {
82 | let mut import = Import::new(producer);
83 |
84 | import
85 | .init_from(input)
86 | .await
87 | .context("failed to initialize cmaf from input")?;
88 |
89 | tracing::info!("initialized");
90 |
91 | import.read_from(input).await?;
92 |
93 | Ok(())
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/rs/hang-cli/src/config.rs:
--------------------------------------------------------------------------------
1 | use clap::{Parser, Subcommand};
2 |
3 | use crate::{client::ClientConfig, server::ServerConfig};
4 |
5 | #[derive(Parser, Clone)]
6 | pub struct Config {
7 | /// Listen for UDP packets on the given address.
8 | #[arg(long, default_value = "[::]:0")]
9 | pub bind: std::net::SocketAddr,
10 |
11 | /// Log configuration.
12 | #[command(flatten)]
13 | pub log: moq_native::log::Args,
14 |
15 | /// The TLS configuration.
16 | #[command(flatten)]
17 | pub tls: moq_native::tls::Args,
18 |
19 | /// If we're publishing or subscribing.
20 | #[command(subcommand)]
21 | pub command: Command,
22 | }
23 |
24 | #[derive(Subcommand, Clone)]
25 | pub enum Command {
26 | /// Host a server, accepting connections from clients.
27 | Serve(ServerConfig),
28 |
29 | /// Publish a video stream to the provided URL.
30 | Publish(ClientConfig),
31 | }
32 |
--------------------------------------------------------------------------------
/rs/hang-cli/src/main.rs:
--------------------------------------------------------------------------------
1 | mod client;
2 | mod config;
3 | mod server;
4 |
5 | use client::*;
6 | use config::*;
7 | use server::*;
8 |
9 | use clap::Parser;
10 |
11 | #[tokio::main]
12 | async fn main() -> anyhow::Result<()> {
13 | let config = Config::parse();
14 | config.log.init();
15 |
16 | match config.command.clone() {
17 | Command::Serve(server) => Server::new(config, server).await?.run(&mut tokio::io::stdin()).await,
18 | Command::Publish(client) => Client::new(config, client).run(&mut tokio::io::stdin()).await,
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/rs/hang-gst/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.1.0](https://github.com/kixelated/moq/releases/tag/hang-gst-v0.1.0) - 2025-05-21
11 |
12 | ### Other
13 |
14 | - Split into Rust/Javascript halves and rebrand as moq-lite/hang ([#376](https://github.com/kixelated/moq/pull/376))
15 |
--------------------------------------------------------------------------------
/rs/hang-gst/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "hang-gst"
3 | description = "Media over QUIC - Gstreamer plugin"
4 | authors = ["Luke Curley"]
5 | repository = "https://github.com/kixelated/moq"
6 | license = "MIT OR Apache-2.0"
7 |
8 | version = "0.1.0"
9 | edition = "2021"
10 |
11 | keywords = ["quic", "http3", "webtransport", "media", "live"]
12 | categories = ["multimedia", "network-programming", "web-programming"]
13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
14 |
15 | [dependencies]
16 | anyhow = { version = "1", features = ["backtrace"] }
17 | env_logger = "0.11"
18 |
19 | gst = { package = "gstreamer", version = "0.23" }
20 | gst-base = { package = "gstreamer-base", version = "0.23" }
21 | hang = { workspace = true }
22 | moq-native = { workspace = true }
23 | #gst-app = { package = "gstreamer-app", version = "0.23", features = ["v1_20"] }
24 |
25 | once_cell = "1"
26 | tokio = { version = "1", features = ["full"] }
27 | url = "2"
28 |
29 | [build-dependencies]
30 | gst-plugin-version-helper = "0.8"
31 |
32 | [lib]
33 | name = "gsthang"
34 | crate-type = ["cdylib", "rlib"]
35 | path = "src/lib.rs"
36 |
--------------------------------------------------------------------------------
/rs/hang-gst/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | A gstreamer plugin utilizing [moq](https://github.com/kixelated/moq).
6 |
7 | # Usage
8 | ## Requirements
9 | - [Rustup](https://www.rust-lang.org/tools/install)
10 | - [Just](https://github.com/casey/just?tab=readme-ov-file#installation)
11 |
12 | ## Setup
13 | We use `just` to simplify the development process.
14 | Check out the [Justfile](justfile) or run `just` to see the available commands.
15 |
16 | Install any other required tools:
17 | ```sh
18 | just setup
19 | ```
20 |
21 | ## Development
22 | First make sure you have a local moq-relay server running:
23 | ```sh
24 | just relay
25 | ```
26 |
27 | Now you can publish and subscribe to a video:
28 | ```sh
29 | # Publish to a localhost moq-relay server
30 | just pub-gst bbb
31 |
32 | # Subscribe from a localhost moq-relay server
33 | just sub bbb
34 | ```
35 |
36 | # License
37 |
38 | Licensed under either:
39 |
40 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
41 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
42 |
--------------------------------------------------------------------------------
/rs/hang-gst/build.rs:
--------------------------------------------------------------------------------
1 | fn main() {
2 | gst_plugin_version_helper::info()
3 | }
4 |
--------------------------------------------------------------------------------
/rs/hang-gst/src/lib.rs:
--------------------------------------------------------------------------------
1 | use gst::glib;
2 |
3 | mod sink;
4 | mod source;
5 |
6 | pub fn plugin_init(plugin: &gst::Plugin) -> Result<(), glib::BoolError> {
7 | env_logger::init();
8 | sink::register(plugin)?;
9 | source::register(plugin)?;
10 |
11 | Ok(())
12 | }
13 |
14 | gst::plugin_define!(
15 | hang,
16 | env!("CARGO_PKG_DESCRIPTION"),
17 | plugin_init,
18 | concat!(env!("CARGO_PKG_VERSION"), "-", env!("COMMIT_ID")),
19 | "MIT/Apache-2.0",
20 | env!("CARGO_PKG_NAME"),
21 | env!("CARGO_PKG_NAME"),
22 | env!("CARGO_PKG_REPOSITORY"),
23 | env!("BUILD_REL_DATE")
24 | );
25 |
--------------------------------------------------------------------------------
/rs/hang-gst/src/sink/mod.rs:
--------------------------------------------------------------------------------
1 | use gst::glib;
2 | use gst::prelude::*;
3 |
4 | mod imp;
5 |
6 | glib::wrapper! {
7 | pub struct HangSink(ObjectSubclass) @extends gst_base::BaseSink, gst::Element, gst::Object;
8 | }
9 |
10 | pub fn register(plugin: &gst::Plugin) -> Result<(), glib::BoolError> {
11 | gst::Element::register(Some(plugin), "hangsink", gst::Rank::NONE, HangSink::static_type())
12 | }
13 |
--------------------------------------------------------------------------------
/rs/hang-gst/src/source/mod.rs:
--------------------------------------------------------------------------------
1 | use gst::glib;
2 | use gst::prelude::*;
3 |
4 | mod imp;
5 |
6 | glib::wrapper! {
7 | pub struct HangSrc(ObjectSubclass) @extends gst::Bin, gst::Element, gst::Object;
8 | }
9 |
10 | pub fn register(plugin: &gst::Plugin) -> Result<(), glib::BoolError> {
11 | gst::Element::register(Some(plugin), "hangsrc", gst::Rank::NONE, HangSrc::static_type())
12 | }
13 |
--------------------------------------------------------------------------------
/rs/hang-wasm/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.1.0](https://github.com/kixelated/moq/releases/tag/hang-wasm-v0.1.0) - 2025-05-21
11 |
12 | ### Other
13 |
14 | - Split into Rust/Javascript halves and rebrand as moq-lite/hang ([#376](https://github.com/kixelated/moq/pull/376))
15 |
--------------------------------------------------------------------------------
/rs/hang-wasm/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "hang-wasm"
3 | authors = ["Luke Curley "]
4 | edition = "2021"
5 | version = "0.1.0"
6 | license = "MIT OR Apache-2.0"
7 | repository = "https://github.com/kixelated/moq"
8 | description = "Web implementation for MoQ utilizing WebAssembly+Typescript"
9 |
10 | [lib]
11 | crate-type = ["cdylib", "rlib"]
12 |
13 | [dependencies]
14 | console_error_panic_hook = "0.1"
15 | gloo-net = "0.6"
16 | hang = { workspace = true }
17 | hex = "0.4"
18 | js-sys = "0.3.77"
19 | rubato = "0.16"
20 | thiserror = "2"
21 | tokio = { workspace = true, features = ["sync"] }
22 | tracing = "0.1"
23 | ts-rs = { version = "10.1", features = ["url-impl"] }
24 | url = "2"
25 | wasm-bindgen = "0.2"
26 | wasm-bindgen-futures = "0.4"
27 | wasm-tracing = "2.0"
28 | web-async = { workspace = true }
29 | web-codecs = "0.3.7"
30 | web-message = { version = "0.0.2", features = [
31 | "Url",
32 | "OffscreenCanvas",
33 | "AudioData",
34 | "MessagePort",
35 | "VideoFrame",
36 | ] }
37 | web-streams = "0.1.2"
38 | web-time = "1"
39 |
40 | [dependencies.web-sys]
41 | version = "0.3.77"
42 | features = [
43 | # DOM
44 | "Window",
45 | "Document",
46 | "HtmlElement",
47 | "Node",
48 | "Text",
49 | "HtmlVideoElement",
50 |
51 | # Custom elements
52 | "HtmlSlotElement",
53 | "AssignedNodesOptions",
54 | "CustomEvent",
55 | "CustomEventInit",
56 | "Event",
57 | "EventTarget",
58 |
59 | # Canvas stuff
60 | "CanvasRenderingContext2d",
61 | "HtmlCanvasElement",
62 | "HtmlImageElement",
63 | "OffscreenCanvas",
64 | "DedicatedWorkerGlobalScope",
65 | "OffscreenCanvasRenderingContext2d",
66 |
67 | # Capture
68 | "MediaStream",
69 | "MediaStreamTrack",
70 | "MediaTrackSettings",
71 | "MediaStreamTrackProcessor",
72 | "MediaStreamTrackProcessorInit",
73 | "ReadableStreamDefaultReader",
74 |
75 | "MessagePort",
76 |
77 | "console",
78 |
79 | "AudioData",
80 | "AudioDataCopyToOptions",
81 | ]
82 |
--------------------------------------------------------------------------------
/rs/hang-wasm/hang-bbb:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -euo pipefail
3 |
4 | # TODO move this script to quic.video
5 |
6 | URL=${URL:-"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"}
7 | REGION=${REGION:-"now"}
8 |
9 | # Download the funny bunny
10 | wget -nv "${URL}" -O "tmp.mp4"
11 |
12 | # Properly fragment the file
13 | ffmpeg -y -i tmp.mp4 \
14 | -c copy \
15 | -f mp4 -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
16 | "fragmented.mp4"
17 |
18 | rm tmp.mp4
19 |
20 | # ffmpeg
21 | # -hide_banner: Hide the banner
22 | # -v quiet: and any other output
23 | # -stats: But we still want some stats on stderr
24 | # -stream_loop -1: Loop the broadcast an infinite number of times
25 | # -re: Output in real-time
26 | # -i "${INPUT}": Read from a file on disk
27 | # -vf "drawtext": Render the current time in the corner of the video
28 | # -an: Disable audio for now
29 | # -b:v 3M: Output video at 3Mbps
30 | # -preset ultrafast: Don't use much CPU at the cost of quality
31 | # -tune zerolatency: Optimize for latency at the cost of quality
32 | # -f mp4: Output to mp4 format
33 | # -movflags: Build a fMP4 file with a frame per fragment
34 | # - | moq-pub: Output to stdout and moq-pub to publish
35 |
36 | # Run ffmpeg
37 | ffmpeg \
38 | -stream_loop -1 \
39 | -hide_banner \
40 | -v quiet \
41 | -re \
42 | -i "fragmented.mp4" \
43 | -vf "drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf:text='${REGION}\: %{gmtime\: %H\\\\\:%M\\\\\:%S.%3N}':x=(W-tw)-24:y=24:fontsize=48:fontcolor=white:box=1:boxcolor=black@0.5" \
44 | -an \
45 | -b:v 3M \
46 | -preset ultrafast \
47 | -tune zerolatency \
48 | -f mp4 \
49 | -movflags cmaf+separate_moof+delay_moov+skip_trailer+frag_every_frame \
50 | - | moq-karp "$@"
--------------------------------------------------------------------------------
/rs/hang-wasm/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@kixelated/hang-wasm",
3 | "type": "module",
4 | "collaborators": ["Luke Curley "],
5 | "description": "Web implementation for MoQ utilizing WebAssembly+Typescript",
6 | "version": "0.1.0",
7 | "license": "MIT OR Apache-2.0",
8 | "repository": "github:kixelated/moq",
9 | "files": ["dist", "pkg"],
10 | "exports": {
11 | ".": "./dist/index.js"
12 | },
13 | "sideEffects": ["./pkg/index.js", "./pkg/snippets/*"],
14 | "scripts": {
15 | "build": "cargo test export_bindings && rspack build && tsc -b && cp ../LICENSE* ./dist && cp ./README.md ./dist",
16 | "check": "cargo check && tsc --noEmit",
17 | "dev": "rspack dev"
18 | },
19 | "devDependencies": {
20 | "@rspack/cli": "^1.3.8",
21 | "@rspack/core": "^1.3.8",
22 | "@types/audioworklet": "^0.0.75",
23 | "@wasm-tool/wasm-pack-plugin": "^1.7.0",
24 | "html-webpack-plugin": "^5.6.3",
25 | "ts-loader": "^9.5.2",
26 | "wasm-pack": "^0.13.1"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/rs/hang-wasm/rspack.config.mjs:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 |
3 | import { fileURLToPath } from "node:url";
4 | import WasmPackPlugin from "@wasm-tool/wasm-pack-plugin";
5 | import HtmlWebpackPlugin from "html-webpack-plugin";
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 |
10 | const config = {
11 | entry: "./src/demo/index.ts",
12 | output: {
13 | path: path.resolve(__dirname, "out"),
14 | filename: "index.js",
15 | },
16 | plugins: [
17 | new WasmPackPlugin({
18 | crateDirectory: path.resolve(__dirname),
19 | outDir: path.resolve(__dirname, "pkg"),
20 | args: "--log-level warn",
21 | outName: "index",
22 | }),
23 | new HtmlWebpackPlugin({
24 | template: "src/demo/watch.html",
25 | filename: "index.html",
26 | }),
27 | new HtmlWebpackPlugin({
28 | template: "src/demo/publish.html",
29 | filename: "publish.html",
30 | }),
31 | ],
32 | mode: "development",
33 | experiments: {
34 | asyncWebAssembly: true,
35 | topLevelAwait: true,
36 | },
37 | // Typescript support
38 | module: {
39 | rules: [
40 | {
41 | test: /\.ts(x)?$/,
42 | loader: "builtin:swc-loader",
43 | exclude: /node_modules/,
44 | },
45 | ],
46 | parser: {
47 | javascript: {
48 | worker: ["*context.audioWorklet.addModule()", "..."],
49 | },
50 | },
51 | },
52 | resolve: {
53 | extensions: [".ts", ".tsx", ".js"],
54 | },
55 | devServer: {
56 | open: true,
57 | hot: false,
58 | liveReload: false,
59 | },
60 | optimization: {
61 | sideEffects: true,
62 | },
63 | };
64 |
65 | export default config;
66 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/bridge.rs:
--------------------------------------------------------------------------------
1 | use crate::{Command, Event, Publish, Result, Watch};
2 |
3 | use tokio::sync::mpsc;
4 | use wasm_bindgen::{prelude::Closure, JsCast};
5 | use web_message::Message;
6 |
7 | pub struct Bridge {
8 | commands: mpsc::UnboundedReceiver,
9 | watch: Watch,
10 | publish: Publish,
11 | }
12 |
13 | impl Bridge {
14 | pub fn new() -> Self {
15 | // Create a channel to receive commands from the worker.
16 | let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
17 |
18 | // Get the worker Worker scope
19 | let global = js_sys::global().unchecked_into::();
20 |
21 | let closure =
22 | Closure::wrap(Box::new(
23 | move |event: web_sys::MessageEvent| match Command::from_message(event.data()) {
24 | Ok(command) => tx.send(command).unwrap(),
25 | Err(err) => tracing::error!(?err, "failed to parse command"),
26 | },
27 | ) as Box);
28 |
29 | global.set_onmessage(Some(closure.as_ref().unchecked_ref()));
30 | closure.forget();
31 |
32 | Self::send(Event::Init);
33 |
34 | Self {
35 | commands: rx,
36 | watch: Watch::default(),
37 | publish: Publish::default(),
38 | }
39 | }
40 |
41 | pub fn send(event: Event) {
42 | let global = js_sys::global().unchecked_into::();
43 | let mut transfer = js_sys::Array::new();
44 | let msg = event.into_message(&mut transfer);
45 | tracing::info!(?msg, "sending event");
46 | global.post_message_with_transfer(&msg, &transfer).unwrap();
47 | }
48 |
49 | pub async fn run(mut self) -> Result<()> {
50 | loop {
51 | tokio::select! {
52 | cmd = self.commands.recv() => {
53 | let cmd = cmd.unwrap();
54 | tracing::debug!(?cmd, "received command");
55 |
56 | match cmd {
57 | Command::Publish(command) => {
58 | if let Err(err) = self.publish.recv(command).await {
59 | tracing::error!(?err, "failed to process publish command");
60 | }
61 | }
62 | Command::Watch(command) => {
63 | if let Err(err) = self.watch.recv(command) {
64 | tracing::error!(?err, "failed to process watch command");
65 | }
66 | }
67 | }
68 | }
69 | // Run these in parallel but they'll never return.
70 | _ = self.watch.run() => {},
71 | _ = self.publish.run() => {},
72 | }
73 | }
74 | }
75 | }
76 |
77 | impl Default for Bridge {
78 | fn default() -> Self {
79 | Self::new()
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/bridge.ts:
--------------------------------------------------------------------------------
1 | import type { Command, Event } from "./message";
2 | export type { Command, Event };
3 |
4 | export class Bridge {
5 | #worker: Promise;
6 |
7 | constructor() {
8 | this.#worker = new Promise((resolve, reject) => {
9 | // NOTE: This file has to be in the root of `src` to work with the current setup.
10 | // That way `../pkg` works pre and post build.
11 | const worker = new Worker(new URL("../pkg", import.meta.url), {
12 | type: "module",
13 | });
14 |
15 | worker.addEventListener(
16 | "message",
17 | (event: MessageEvent) => {
18 | if (event.data === "Init") {
19 | resolve(worker);
20 | } else {
21 | console.error("unknown init event", event.data);
22 | reject(new Error(`Unknown init event: ${event.data}`));
23 | }
24 | },
25 | { once: true },
26 | );
27 | });
28 | }
29 |
30 | addEventListener(callback: (event: Event) => void) {
31 | this.#worker.then((worker) => {
32 | worker.addEventListener("message", (event: MessageEvent) => {
33 | callback(event.data);
34 | });
35 | });
36 | }
37 |
38 | postMessage(cmd: Command) {
39 | const transfer = collectTransferables(cmd);
40 | this.#worker.then((worker) => worker.postMessage(cmd, { transfer }));
41 | }
42 | }
43 |
44 | function collectTransferables(obj: unknown, out: Transferable[] = []): Transferable[] {
45 | if (obj && typeof obj === "object") {
46 | if (isTransferable(obj)) {
47 | out.push(obj);
48 | } else if (Array.isArray(obj)) {
49 | for (const item of obj as unknown[]) {
50 | collectTransferables(item, out);
51 | }
52 | } else {
53 | for (const value of Object.values(obj)) {
54 | collectTransferables(value, out);
55 | }
56 | }
57 | }
58 | return out;
59 | }
60 |
61 | function isTransferable(value: unknown): value is Transferable {
62 | return (
63 | value instanceof MessagePort ||
64 | value instanceof OffscreenCanvas ||
65 | value instanceof ReadableStream ||
66 | value instanceof WritableStream ||
67 | value instanceof TransformStream ||
68 | value instanceof ArrayBuffer ||
69 | // Add others if needed
70 | false
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/demo/index.ts:
--------------------------------------------------------------------------------
1 | import { WatchElement } from "..";
2 | export { WatchElement };
3 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/demo/publish.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | MoQ Demo
8 |
17 |
18 |
19 |
20 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | Tips:
52 |
53 | Other demos:
54 |
57 |
58 |
59 |
71 |
72 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/error.rs:
--------------------------------------------------------------------------------
1 | use hang::moq_lite;
2 | use wasm_bindgen::JsValue;
3 |
4 | use crate::ConnectError;
5 |
6 | #[derive(Debug, thiserror::Error)]
7 | pub enum Error {
8 | #[error("moq error: {0}")]
9 | Moq(#[from] moq_lite::Error),
10 |
11 | #[error("webcodecs error: {0}")]
12 | WebCodecs(#[from] web_codecs::Error),
13 |
14 | #[error("streams error: {0}")]
15 | Streams(#[from] web_streams::Error),
16 |
17 | #[error("karp error: {0}")]
18 | Karp(#[from] hang::Error),
19 |
20 | #[error("offline")]
21 | Offline,
22 |
23 | #[error("unsupported")]
24 | Unsupported,
25 |
26 | #[error("closed")]
27 | Closed,
28 |
29 | #[error("capture failed")]
30 | InitFailed,
31 |
32 | #[error("no broadcast")]
33 | NoBroadcast,
34 |
35 | #[error("no catalog")]
36 | NoCatalog,
37 |
38 | #[error("no track")]
39 | NoTrack,
40 |
41 | #[error("not visible")]
42 | NotVisible,
43 |
44 | #[error("invalid dimensions")]
45 | InvalidDimensions,
46 |
47 | #[error("unclassified: {0}")]
48 | Js(String),
49 |
50 | #[error("connect error: {0}")]
51 | Connect(#[from] ConnectError),
52 |
53 | #[error("resampler init: {0}")]
54 | ResamplerInit(#[from] rubato::ResamplerConstructionError),
55 |
56 | #[error("resampler: {0}")]
57 | Resampler(#[from] rubato::ResampleError),
58 | }
59 |
60 | pub type Result = std::result::Result;
61 |
62 | impl From for JsValue {
63 | fn from(err: Error) -> JsValue {
64 | JsValue::from_str(&format!("{}", err))
65 | }
66 | }
67 |
68 | impl From for Error {
69 | fn from(value: JsValue) -> Self {
70 | Error::Js(value.as_string().unwrap_or_default())
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/index.ts:
--------------------------------------------------------------------------------
1 | export { Watch, WatchElement } from "./watch";
2 | export { Publish, PublishElement } from "./publish";
3 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/lib.rs:
--------------------------------------------------------------------------------
1 | mod bridge;
2 | mod connect;
3 | mod error;
4 | mod message;
5 | mod publish;
6 | mod watch;
7 | mod worklet;
8 |
9 | pub use bridge::*;
10 | pub use connect::*;
11 | pub use error::*;
12 | pub use message::*;
13 | pub use publish::*;
14 | pub use watch::*;
15 | pub use worklet::*;
16 |
17 | use wasm_bindgen::prelude::wasm_bindgen;
18 |
19 | #[wasm_bindgen(start)]
20 | pub fn start() {
21 | // print pretty errors in wasm https://github.com/rustwasm/console_error_panic_hook
22 | // This is not needed for tracing_wasm to work, but it is a common tool for getting proper error line numbers for panics.
23 | console_error_panic_hook::set_once();
24 |
25 | let config = wasm_tracing::WasmLayerConfig {
26 | max_level: tracing::Level::DEBUG,
27 | ..Default::default()
28 | };
29 | wasm_tracing::set_as_global_default_with_config(config).expect("failed to install logger");
30 |
31 | tracing::info!("creating bridge");
32 |
33 | wasm_bindgen_futures::spawn_local(async move {
34 | let bridge = Bridge::new();
35 | if let Err(err) = bridge.run().await {
36 | tracing::error!(?err, "bridge terminated");
37 | }
38 | });
39 | }
40 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/message.rs:
--------------------------------------------------------------------------------
1 | use ts_rs::TS;
2 | use web_message::Message;
3 |
4 | use crate::{PublishCommand, WatchCommand};
5 |
6 | #[derive(Message, Debug, TS)]
7 | #[ts(export, export_to = "../src/message.ts")]
8 | pub enum Command {
9 | // Responsible for rendering a broadcast.
10 | Watch(WatchCommand),
11 |
12 | // Responsible for publishing a broadcast.
13 | Publish(PublishCommand),
14 | }
15 |
16 | #[derive(Debug, Message, TS)]
17 | #[ts(export, export_to = "../src/message.ts")]
18 | pub enum Event {
19 | Init,
20 | Connection(ConnectionStatus),
21 | }
22 |
23 | #[derive(Debug, Message, TS)]
24 | #[ts(export, export_to = "../src/message.ts")]
25 | pub enum ConnectionStatus {
26 | Disconnected,
27 | Connecting,
28 | Connected,
29 | Live,
30 | Offline,
31 | Error(String),
32 | }
33 |
34 | impl From for Event {
35 | fn from(status: ConnectionStatus) -> Self {
36 | Event::Connection(status)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/message.ts:
--------------------------------------------------------------------------------
1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2 | import type { PublishCommand } from "./publish/message";
3 | import type { WatchCommand } from "./watch/message";
4 |
5 | export type Command = { "Watch": WatchCommand } | { "Publish": PublishCommand };
6 |
7 | export type ConnectionStatus = "Disconnected" | "Connecting" | "Connected" | "Live" | "Offline" | { "Error": string };
8 |
9 | export type Event = "Init" | { "Connection": ConnectionStatus };
10 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/publish/audio.rs:
--------------------------------------------------------------------------------
1 | use hang::moq_lite;
2 |
3 | use crate::{Error, Result};
4 |
5 | pub struct PublishAudio {
6 | // The config that we are using for the encoder.
7 | config: web_codecs::AudioEncoderConfig,
8 |
9 | // The track that we are publishing.
10 | track: hang::TrackProducer,
11 |
12 | // The encoder accepts raw frames and spits out encoded frames.
13 | encoder: web_codecs::AudioEncoder,
14 | encoded: web_codecs::AudioEncoded,
15 |
16 | // When set, publish to the given broadcast.
17 | broadcast: Option,
18 | }
19 |
20 | impl PublishAudio {
21 | pub async fn init(id: usize, channel_count: u32, sample_rate: u32) -> Result {
22 | let track = moq_lite::Track {
23 | name: format!("audio_{}", id),
24 | priority: 1,
25 | }
26 | .produce();
27 |
28 | let config = Self::config(channel_count, sample_rate).await?;
29 | let (encoder, encoded) = config.clone().init()?;
30 |
31 | Ok(Self {
32 | config,
33 | track: track.into(),
34 | encoder,
35 | encoded,
36 | broadcast: None,
37 | })
38 | }
39 |
40 | pub async fn encode(&mut self, frame: web_sys::AudioData) -> Result<()> {
41 | let frame = frame.into();
42 | self.encoder.encode(&frame)?;
43 |
44 | Ok(())
45 | }
46 |
47 | pub async fn run(&mut self) -> Result<()> {
48 | while let Some(frame) = self.encoded.frame().await? {
49 | if let Some(mut broadcast) = self.broadcast.take() {
50 | self.publish_to(&mut broadcast);
51 | }
52 |
53 | self.track.write(hang::Frame {
54 | timestamp: frame.timestamp,
55 | keyframe: frame.keyframe,
56 | payload: frame.payload,
57 | });
58 | }
59 |
60 | Ok(())
61 | }
62 |
63 | async fn config(channel_count: u32, sample_rate: u32) -> Result {
64 | let config = web_codecs::AudioEncoderConfig {
65 | codec: "opus".to_string(), // TODO more codecs
66 | sample_rate: Some(sample_rate as _),
67 | channel_count: Some(channel_count),
68 | bitrate: Some(128_000), // TODO configurable
69 | };
70 |
71 | if config.is_supported().await? {
72 | Ok(config)
73 | } else {
74 | Err(Error::Unsupported)
75 | }
76 | }
77 |
78 | pub fn publish_to(&mut self, broadcast: &mut hang::BroadcastProducer) {
79 | if let Some(config) = self.encoded.config() {
80 | let info = hang::AudioTrack {
81 | track: self.track.inner.info.clone(),
82 | config: hang::AudioConfig {
83 | codec: config.codec.into(),
84 | description: config.description,
85 | sample_rate: config.sample_rate,
86 | channel_count: config.channel_count,
87 | bitrate: self.config.bitrate.map(|b| b as _),
88 | },
89 | };
90 |
91 | broadcast.add_audio(self.track.consume(), info);
92 | } else {
93 | // Save a reference for later after fully initializing.
94 | self.broadcast = Some(broadcast.clone());
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/publish/index.ts:
--------------------------------------------------------------------------------
1 | import { Bridge } from "../bridge";
2 |
3 | export class Publish {
4 | #bridge = new Bridge();
5 |
6 | #url: URL | null = null;
7 | #device: "camera" | "screen" | null = null;
8 | #video = true;
9 | #audio = true;
10 | #preview: HTMLVideoElement | null = null;
11 |
12 | get url(): URL | null {
13 | return this.#url;
14 | }
15 |
16 | set url(url: URL | null) {
17 | this.#url = url;
18 | this.#bridge.postMessage({ Publish: { Connect: url?.toString() ?? null } });
19 | }
20 |
21 | get device(): "camera" | "screen" | null {
22 | return this.#device;
23 | }
24 |
25 | get video(): boolean {
26 | return this.#video;
27 | }
28 |
29 | get audio(): boolean {
30 | return this.#audio;
31 | }
32 |
33 | set device(device: "camera" | "screen" | null) {
34 | this.#device = device;
35 | }
36 |
37 | set video(video: boolean) {
38 | this.#video = video;
39 | }
40 |
41 | set audio(audio: boolean) {
42 | this.#audio = audio;
43 | }
44 |
45 | get preview(): HTMLVideoElement | null {
46 | return this.#preview;
47 | }
48 |
49 | set preview(preview: HTMLVideoElement | null) {
50 | this.#preview = preview;
51 | }
52 | }
53 |
54 | // A custom element making it easier to insert into the DOM.
55 | export class PublishElement extends HTMLElement {
56 | static observedAttributes = ["url", "device", "no-video", "no-audio"];
57 |
58 | // Expose the library instance for easy access.
59 | readonly lib = new Publish();
60 |
61 | constructor() {
62 | super();
63 |
64 | // Attach a element to the root used for previewing the video.
65 | const video = document.createElement("video");
66 | this.lib.preview = video;
67 |
68 | const slot = document.createElement("slot");
69 | slot.addEventListener("slotchange", () => {
70 | for (const el of slot.assignedElements({ flatten: true })) {
71 | if (el instanceof HTMLVideoElement) {
72 | this.lib.preview = el;
73 | return;
74 | }
75 | }
76 |
77 | this.lib.preview = null;
78 | });
79 | slot.appendChild(video);
80 |
81 | this.attachShadow({ mode: "open" }).appendChild(slot);
82 | }
83 |
84 | attributeChangedCallback(name: string, _oldValue: string | undefined, newValue: string | undefined) {
85 | if (name === "url") {
86 | this.lib.url = newValue ? new URL(newValue) : null;
87 | } else if (name === "device") {
88 | this.lib.device = newValue as "camera" | "screen" | null;
89 | } else if (name === "no-video") {
90 | this.lib.video = !newValue;
91 | } else if (name === "no-audio") {
92 | this.lib.audio = !newValue;
93 | }
94 | }
95 | }
96 |
97 | customElements.define("hang-publish", PublishElement);
98 |
99 | declare global {
100 | interface HTMLElementTagNameMap {
101 | "hang-publish": PublishElement;
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/publish/message.rs:
--------------------------------------------------------------------------------
1 | use ts_rs::TS;
2 | use url::Url;
3 | use web_message::Message;
4 |
5 | #[derive(Debug, Message, TS)]
6 | #[ts(export, export_to = "../src/publish/message.ts")]
7 | pub enum PublishCommand {
8 | // Publish a broadcast with the given URL.
9 | // ex. https://relay.quic.video/demo/bbb
10 | Connect(Option),
11 |
12 | // Create a new audio track.
13 | AudioInit {
14 | sample_rate: u32,
15 | channel_count: u32,
16 | },
17 |
18 | // Encode and publish an audio frame.
19 | #[ts(type = "AudioData")]
20 | AudioFrame(web_sys::AudioData),
21 |
22 | // Close the audio track.
23 | AudioClose,
24 |
25 | // Create a new video track.
26 | VideoInit {
27 | width: u32,
28 | height: u32,
29 | },
30 |
31 | // Encode and publish a video frame.
32 | #[ts(type = "VideoFrame")]
33 | VideoFrame(web_sys::VideoFrame),
34 |
35 | // Close the video track.
36 | VideoClose,
37 | }
38 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/publish/message.ts:
--------------------------------------------------------------------------------
1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2 |
3 | export type PublishCommand = { "Connect": string | null } | { "AudioInit": { sample_rate: number, channel_count: number, } } | { "AudioFrame": AudioData } | "AudioClose" | { "VideoInit": { width: number, height: number, } } | { "VideoFrame": VideoFrame } | "VideoClose";
4 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/publish/mod.rs:
--------------------------------------------------------------------------------
1 | mod audio;
2 | mod message;
3 | mod video;
4 |
5 | pub use audio::*;
6 | pub use message::*;
7 | pub use video::*;
8 |
9 | use hang::{moq_lite::Session, BroadcastProducer};
10 |
11 | use crate::{Connect, Result};
12 |
13 | #[derive(Default)]
14 | pub struct Publish {
15 | connect: Option,
16 | broadcast: Option,
17 |
18 | audio: Option,
19 | audio_id: usize,
20 |
21 | video: Option,
22 | video_id: usize,
23 | }
24 |
25 | impl Publish {
26 | pub async fn recv(&mut self, command: PublishCommand) -> Result<()> {
27 | match command {
28 | PublishCommand::Connect(url) => {
29 | self.connect = None;
30 |
31 | if let Some(url) = url {
32 | self.connect = Some(Connect::new(url));
33 | }
34 | }
35 | PublishCommand::VideoInit { width, height } => {
36 | self.video = Some(PublishVideo::init(self.video_id, width, height).await?);
37 | self.video_id += 1;
38 |
39 | if let Some(broadcast) = self.broadcast.as_mut() {
40 | self.video.as_mut().unwrap().publish_to(broadcast);
41 | }
42 | }
43 | PublishCommand::VideoFrame(frame) => {
44 | // Don't encode anything until connecting to a room, for privacy reasons.
45 | match self.video.as_mut() {
46 | Some(video) => video.encode(frame).await?,
47 | None => frame.close(),
48 | };
49 | }
50 | PublishCommand::VideoClose => {
51 | self.video = None;
52 | }
53 | PublishCommand::AudioInit {
54 | sample_rate,
55 | channel_count,
56 | } => {
57 | self.audio = Some(PublishAudio::init(self.audio_id, channel_count, sample_rate).await?);
58 | self.audio_id += 1;
59 |
60 | if let Some(broadcast) = self.broadcast.as_mut() {
61 | self.audio.as_mut().unwrap().publish_to(broadcast);
62 | }
63 | }
64 | PublishCommand::AudioFrame(frame) => {
65 | match self.audio.as_mut() {
66 | Some(audio) => audio.encode(frame).await?,
67 | None => frame.close(),
68 | };
69 | }
70 | PublishCommand::AudioClose => {
71 | self.audio = None;
72 | }
73 | };
74 |
75 | Ok(())
76 | }
77 |
78 | pub async fn run(&mut self) -> Result<()> {
79 | loop {
80 | tokio::select! {
81 | Some(session) = async { Some(self.connect.as_mut()?.established().await) } => {
82 | let connect = self.connect.take().unwrap();
83 | self.connected(connect, session?)?;
84 | }
85 | Some(Err(err)) = async { Some(self.audio.as_mut()?.run().await) } => return Err(err),
86 | Some(Err(err)) = async { Some(self.video.as_mut()?.run().await) } => return Err(err),
87 | // Block forever if there's nothing to do.
88 | else => std::future::pending::<()>().await,
89 | };
90 | }
91 | }
92 |
93 | fn connected(&mut self, connect: Connect, session: Session) -> Result<()> {
94 | let path = connect.path.strip_prefix("/").unwrap().to_string();
95 | let mut room = hang::Room::new(session, path.to_string());
96 | let mut broadcast = room.join(path);
97 |
98 | if let Some(video) = self.video.as_mut() {
99 | video.publish_to(&mut broadcast);
100 | }
101 |
102 | if let Some(audio) = self.audio.as_mut() {
103 | audio.publish_to(&mut broadcast);
104 | }
105 |
106 | Ok(())
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/watch/message.rs:
--------------------------------------------------------------------------------
1 | use ts_rs::TS;
2 | use url::Url;
3 | use web_message::Message;
4 |
5 | #[derive(Debug, Message, TS)]
6 | #[ts(export, export_to = "../src/watch/message.ts")]
7 | pub enum WatchCommand {
8 | // Join a room at the given URL, or none to leave the current room.
9 | Connect(Option),
10 |
11 | // Render the video to the given canvas, or none to disable rendering.
12 | // NOTE: You can only transfer a canvas once; use Visible to show/hide the video.
13 | #[ts(type = "OffscreenCanvas | null")]
14 | Canvas(Option),
15 |
16 | // Set the worklet port so we can send audio data to it.
17 | Worklet {
18 | #[ts(type = "MessagePort | null")]
19 | port: Option,
20 | sample_rate: u32,
21 | },
22 |
23 | // Set the latency of the video.
24 | // Default: 0
25 | Latency(u32),
26 |
27 | // Pause or resume the video.
28 | // Default: false
29 | Paused(bool),
30 |
31 | // Set the visibility of the video.
32 | // Default: true
33 | Visible(bool),
34 |
35 | // Set the muted state of the audio.
36 | // If true, then no audio will be downloaded or played.
37 | //
38 | // Default: false
39 | Muted(bool),
40 | }
41 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/watch/message.ts:
--------------------------------------------------------------------------------
1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2 |
3 | export type WatchCommand = { "Connect": string | null } | { "Canvas": OffscreenCanvas | null } | { "Worklet": { port: MessagePort | null, sample_rate: number, } } | { "Latency": number } | { "Paused": boolean } | { "Visible": boolean } | { "Muted": boolean };
4 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/watch/mod.rs:
--------------------------------------------------------------------------------
1 | mod audio;
2 | mod message;
3 | mod video;
4 |
5 | use std::time::Duration;
6 |
7 | pub use audio::*;
8 | pub use message::*;
9 | pub use video::*;
10 |
11 | use crate::{Bridge, Connect, ConnectionStatus, Result};
12 | use hang::{moq_lite, Catalog};
13 | use moq_lite::Session;
14 |
15 | #[derive(Default)]
16 | pub struct Watch {
17 | connect: Option,
18 | broadcast: Option,
19 | audio: Audio,
20 | video: Video,
21 | }
22 |
23 | impl Watch {
24 | pub fn recv(&mut self, command: WatchCommand) -> Result<()> {
25 | match command {
26 | WatchCommand::Connect(url) => {
27 | self.connect = None;
28 |
29 | if let Some(url) = url {
30 | self.connect = Some(Connect::new(url));
31 | Bridge::send(ConnectionStatus::Connecting.into());
32 | } else {
33 | Bridge::send(ConnectionStatus::Disconnected.into());
34 | }
35 | }
36 | WatchCommand::Canvas(canvas) => self.video.set_canvas(canvas),
37 | WatchCommand::Worklet { port, sample_rate } => self.audio.set_worklet(port, sample_rate),
38 | WatchCommand::Latency(latency) => self.video.set_latency(Duration::from_millis(latency.into())),
39 | WatchCommand::Paused(paused) => self.video.set_paused(paused),
40 | WatchCommand::Visible(visible) => self.video.set_visible(visible),
41 | WatchCommand::Muted(muted) => self.audio.set_muted(muted),
42 | };
43 |
44 | Ok(())
45 | }
46 |
47 | pub async fn run(&mut self) {
48 | loop {
49 | tokio::select! {
50 | Some(session) = async { Some(self.connect.as_mut()?.established().await) } => {
51 | let connect = self.connect.take().unwrap();
52 | let path = connect.path.strip_prefix("/").unwrap();
53 | self.set_session(path, session.map_err(Into::into))
54 | },
55 | Some(catalog) = async { Some(self.broadcast.as_mut()?.catalog.next().await) } => {
56 | let broadcast = self.broadcast.take().unwrap(); // unset just in case
57 | self.set_catalog(broadcast, catalog.map_err(Into::into));
58 | }
59 | // Run these in parallel but they'll never return.
60 | _ = self.audio.run() => {},
61 | _ = self.video.run() => {},
62 | }
63 | }
64 | }
65 |
66 | fn set_session(&mut self, broadcast: &str, session: Result) {
67 | let session = match session {
68 | Ok(session) => session,
69 | Err(err) => {
70 | Bridge::send(ConnectionStatus::Error(err.to_string()).into());
71 | return;
72 | }
73 | };
74 |
75 | let consumer = session.consume(broadcast);
76 | self.broadcast = Some(hang::BroadcastConsumer::new(consumer));
77 |
78 | Bridge::send(ConnectionStatus::Connected.into());
79 | }
80 |
81 | fn set_catalog(&mut self, broadcast: hang::BroadcastConsumer, catalog: Result>) {
82 | let catalog = match catalog {
83 | Ok(catalog) => catalog,
84 | Err(err) => {
85 | Bridge::send(ConnectionStatus::Error(err.to_string()).into());
86 | return;
87 | }
88 | };
89 |
90 | if catalog.is_some() {
91 | // TODO don't send duplicate events
92 | Bridge::send(ConnectionStatus::Live.into());
93 | self.broadcast = Some(broadcast.clone());
94 | } else {
95 | Bridge::send(ConnectionStatus::Offline.into());
96 | self.broadcast.take();
97 | }
98 |
99 | self.audio.set_catalog(Some(broadcast.clone()), catalog.clone());
100 | self.video.set_catalog(Some(broadcast), catalog);
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/worklet/index.ts:
--------------------------------------------------------------------------------
1 | import type { WorkletCommand } from "./message";
2 |
3 | type Frame = WorkletCommand["Frame"];
4 |
5 | class Renderer extends AudioWorkletProcessor {
6 | #current: Frame | null = null;
7 | #current_pos = 0;
8 | #queued: Frame[] = [];
9 |
10 | constructor() {
11 | // The super constructor call is required.
12 | super();
13 | this.port.onmessage = this.onMessage.bind(this);
14 | }
15 |
16 | onMessage(e: MessageEvent) {
17 | const msg = e.data;
18 | if (msg.Frame) {
19 | this.onFrame(msg.Frame);
20 | }
21 | }
22 |
23 | onFrame(frame: Frame) {
24 | if (this.#current === null) {
25 | this.#current = frame;
26 | } else if (this.#queued.length < 4) {
27 | this.#queued.push(frame);
28 | } else {
29 | /* TODO make a metric for this
30 | console.debug(
31 | "frame buffer overflow, samples lost:",
32 | this.#queued.reduce((acc, f) => acc + f.channels[0].length, 0),
33 | );
34 | */
35 |
36 | // Start the queue over to reset latency.
37 | this.#queued = [frame];
38 | }
39 | }
40 |
41 | // Inputs and outputs in groups of 128 samples.
42 | process(
43 | _inputs: Float32Array[][],
44 | outputs_all: Float32Array[][],
45 | _parameters: Record,
46 | ): boolean {
47 | if (this.#current === null) {
48 | return true;
49 | }
50 |
51 | // I don't know why, but the AudioWorkletProcessor interface gives us multiple outputs.
52 | const outputs = outputs_all[0];
53 |
54 | let offset = 0;
55 |
56 | // Keep looping until we've written the entire output buffer.
57 | while (this.#current !== null && offset < outputs[0].length) {
58 | let written = 0;
59 |
60 | // Loop over each channel and copy the current frame into the output buffer.
61 | for (let i = 0; i < Math.min(outputs.length, this.#current.channels.length); i++) {
62 | const output = outputs[i];
63 | const input = this.#current.channels[i];
64 |
65 | const current = input.subarray(
66 | this.#current_pos,
67 | Math.min(this.#current_pos + output.length - offset, input.length),
68 | );
69 | output.set(current, offset);
70 |
71 | // This will be the same value for every channel, so we're lazy.
72 | written = current.length;
73 | }
74 |
75 | // Advance the current position and offset.
76 | this.#current_pos += written;
77 | offset += written;
78 |
79 | // If we've reached the end of the current frame, advance to the next frame.
80 | if (this.#current_pos >= this.#current.channels[0].length) {
81 | this.#current_pos = 0;
82 | this.#current = this.#queued.shift() ?? null;
83 | }
84 | }
85 |
86 | /* TODO make this into a metric
87 | if (offset < outputs[0].length) {
88 | console.warn("output buffer underrun, samples missing:", outputs[0].length - offset);
89 | }
90 | */
91 |
92 | return true;
93 | }
94 | }
95 |
96 | registerProcessor("renderer", Renderer);
97 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/worklet/message.rs:
--------------------------------------------------------------------------------
1 | use ts_rs::TS;
2 | use web_message::Message;
3 |
4 | #[derive(Debug, Message, TS)]
5 | #[ts(export, export_to = "../src/worklet/message.ts")]
6 | pub enum WorkletCommand {
7 | /// A frame of audio data split into channels.
8 | // I tried to transfer an AudioData object to the Worklet but it silently failed.
9 | // So instead we need to copy/allocate the data into a Vec and transfer that.
10 | Frame {
11 | #[ts(type = "Float32Array[]")]
12 | channels: Vec,
13 | timestamp: u64,
14 | },
15 | }
16 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/worklet/message.ts:
--------------------------------------------------------------------------------
1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2 |
3 | export type WorkletCommand = { "Frame": { channels: Float32Array[], timestamp: bigint, } };
4 |
--------------------------------------------------------------------------------
/rs/hang-wasm/src/worklet/mod.rs:
--------------------------------------------------------------------------------
1 | mod message;
2 |
3 | pub use message::*;
4 |
--------------------------------------------------------------------------------
/rs/hang-wasm/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "src"
4 | ],
5 | "exclude": [
6 | "node_modules",
7 | "dist",
8 | "pkg"
9 | ],
10 | "compilerOptions": {
11 | "lib": [
12 | "esnext",
13 | "dom"
14 | ],
15 | "target": "esnext",
16 | "module": "esnext",
17 | "moduleResolution": "bundler",
18 | "declaration": true,
19 | "declarationMap": true,
20 | "isolatedModules": true,
21 | "sourceMap": true,
22 | // https://www.typescriptlang.org/tsconfig/#Type_Checking_6248
23 | "allowUnreachableCode": false,
24 | "allowUnusedLabels": false,
25 | "alwaysStrict": true,
26 | "exactOptionalPropertyTypes": false, // makes ? less usable
27 | "noFallthroughCasesInSwitch": true,
28 | "noImplicitAny": true,
29 | "noImplicitOverride": true,
30 | "noImplicitReturns": true,
31 | "noImplicitThis": true,
32 | "noPropertyAccessFromIndexSignature": true,
33 | "noUncheckedIndexedAccess": false, // Allows [0] access to arrays
34 | "noUnusedLocals": true,
35 | "noUnusedParameters": true,
36 | "strict": true,
37 | "strictBindCallApply": true,
38 | "strictBuiltinIteratorReturn": true,
39 | "strictFunctionTypes": true,
40 | "strictNullChecks": true,
41 | "strictPropertyInitialization": true,
42 | "useUnknownInCatchVariables": true,
43 | // Disable enums and weird Typescript features.
44 | "erasableSyntaxOnly": true,
45 | "skipLibCheck": true
46 | }
47 | }
--------------------------------------------------------------------------------
/rs/hang/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.2.0](https://github.com/kixelated/moq/compare/hang-v0.1.0...hang-v0.2.0) - 2025-05-21
11 |
12 | ### Other
13 |
14 | - Split into Rust/Javascript halves and rebrand as moq-lite/hang ([#376](https://github.com/kixelated/moq/pull/376))
15 |
--------------------------------------------------------------------------------
/rs/hang/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "hang"
3 | description = "Media over QUIC"
4 | authors = ["Luke Curley "]
5 | repository = "https://github.com/kixelated/moq"
6 | license = "MIT OR Apache-2.0"
7 |
8 | version = "0.2.0"
9 | edition = "2021"
10 |
11 | keywords = ["quic", "http3", "webtransport", "media", "live"]
12 | categories = ["multimedia", "network-programming", "web-programming"]
13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
14 |
15 | [dependencies]
16 | bytes = "1.10"
17 | futures = "0.3"
18 | hex = "0.4"
19 | lazy_static = "1"
20 | moq-lite = { workspace = true, features = ["serde"] }
21 | mp4-atom = { version = "0.8.1", features = ["tokio", "bytes", "serde"] }
22 | regex = "1"
23 | serde = { workspace = true }
24 | serde_json = "1"
25 | serde_with = { version = "3", features = ["hex"] }
26 | thiserror = "2"
27 | tokio = { workspace = true, features = ["macros"] }
28 | tracing = "0.1"
29 | web-async = { workspace = true }
30 |
31 | [dependencies.derive_more]
32 | version = "2"
33 | features = ["from", "display", "debug"]
34 |
--------------------------------------------------------------------------------
/rs/hang/src/audio/aac.rs:
--------------------------------------------------------------------------------
1 | use super::Error;
2 | use serde::{Deserialize, Serialize};
3 |
4 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
5 | pub struct AAC {
6 | pub profile: u8,
7 | // freq_index
8 | // chan_conf
9 | }
10 |
11 | impl std::fmt::Display for AAC {
12 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13 | write!(f, "mp4a.40.{}", self.profile)
14 | }
15 | }
16 |
17 | impl std::str::FromStr for AAC {
18 | type Err = Error;
19 |
20 | fn from_str(s: &str) -> Result {
21 | let remain = s.strip_prefix("mp4a.40.").ok_or(Error::InvalidCodec)?;
22 | Ok(Self {
23 | profile: u8::from_str(remain)?,
24 | })
25 | }
26 | }
27 |
28 | #[cfg(test)]
29 | mod test {
30 | use std::str::FromStr;
31 |
32 | use super::*;
33 |
34 | #[test]
35 | fn test_aac() {
36 | let encoded = "mp4a.40.2";
37 | let decoded = AAC { profile: 2 };
38 |
39 | let output = AAC::from_str(encoded).expect("failed to parse AAC string");
40 | assert_eq!(output, decoded);
41 |
42 | let output = decoded.to_string();
43 | assert_eq!(output, encoded);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/rs/hang/src/audio/codec.rs:
--------------------------------------------------------------------------------
1 | use super::*;
2 |
3 | use derive_more::{Display, From};
4 | use std::str::FromStr;
5 |
6 | #[derive(Debug, Clone, PartialEq, Eq, Display, From)]
7 | pub enum AudioCodec {
8 | AAC(AAC),
9 |
10 | #[display("opus")]
11 | Opus,
12 |
13 | #[display("{_0}")]
14 | Unknown(String),
15 | }
16 |
17 | impl FromStr for AudioCodec {
18 | type Err = Error;
19 |
20 | fn from_str(s: &str) -> Result {
21 | if s.starts_with("mp4a.40.") {
22 | return AAC::from_str(s).map(Into::into);
23 | } else if s == "opus" {
24 | return Ok(Self::Opus);
25 | }
26 |
27 | Ok(Self::Unknown(s.to_string()))
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/rs/hang/src/audio/mod.rs:
--------------------------------------------------------------------------------
1 | mod aac;
2 | mod codec;
3 |
4 | pub use aac::*;
5 | pub use codec::*;
6 |
7 | use crate::Track;
8 | use bytes::Bytes;
9 |
10 | use super::Error;
11 | use serde::{Deserialize, Serialize};
12 | use serde_with::{hex::Hex, DisplayFromStr};
13 |
14 | #[serde_with::serde_as]
15 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
16 | #[serde(rename_all = "camelCase")]
17 | pub struct AudioTrack {
18 | // Generic information about the track
19 | pub track: Track,
20 |
21 | // The configuration of the audio track
22 | pub config: AudioConfig,
23 | }
24 |
25 | #[serde_with::serde_as]
26 | #[serde_with::skip_serializing_none]
27 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
28 | #[serde(rename_all = "camelCase")]
29 | /// AudioDecoderConfig from WebCodecs
30 | /// https://www.w3.org/TR/webcodecs/#audio-decoder-config
31 | pub struct AudioConfig {
32 | // The codec, see the registry for details:
33 | // https://w3c.github.io/webcodecs/codec_registry.html
34 | #[serde_as(as = "DisplayFromStr")]
35 | pub codec: AudioCodec,
36 |
37 | // The sample rate of the audio in Hz
38 | pub sample_rate: u32,
39 |
40 | // The number of channels in the audio
41 | #[serde(rename = "numberOfChannels")]
42 | pub channel_count: u32,
43 |
44 | // The bitrate of the audio track in bits per second
45 | #[serde(default)]
46 | pub bitrate: Option,
47 |
48 | // Some codecs include a description so the decoder can be initialized without extra data.
49 | // If not provided, there may be in-band metadata (marginally higher overhead).
50 | #[serde(default)]
51 | #[serde_as(as = "Option")]
52 | pub description: Option,
53 | }
54 |
--------------------------------------------------------------------------------
/rs/hang/src/broadcast.rs:
--------------------------------------------------------------------------------
1 | use crate::track::TrackConsumer;
2 | use crate::{AudioTrack, Catalog, CatalogConsumer, CatalogProducer, TrackProducer, VideoTrack};
3 | use moq_lite::Track;
4 | use web_async::spawn;
5 |
6 | /// A wrapper around a moq_lite::BroadcastProducer that produces a `catalog.json` track.
7 | #[derive(Clone)]
8 | pub struct BroadcastProducer {
9 | pub catalog: CatalogProducer,
10 | pub inner: moq_lite::BroadcastProducer,
11 | }
12 |
13 | impl Default for BroadcastProducer {
14 | fn default() -> Self {
15 | Self::new()
16 | }
17 | }
18 |
19 | impl BroadcastProducer {
20 | pub fn new() -> Self {
21 | let catalog = Catalog::default().produce();
22 | let inner = moq_lite::BroadcastProducer::new();
23 | inner.insert(catalog.consume().track);
24 |
25 | Self { catalog, inner }
26 | }
27 |
28 | pub fn consume(&self) -> BroadcastConsumer {
29 | BroadcastConsumer {
30 | catalog: self.catalog.consume(),
31 | inner: self.inner.consume(),
32 | }
33 | }
34 |
35 | /// Add a video track to the broadcast.
36 | pub fn add_video(&mut self, track: TrackConsumer, info: VideoTrack) {
37 | self.inner.insert(track.inner.clone());
38 | self.catalog.add_video(info.clone());
39 | self.catalog.publish();
40 |
41 | let mut this = self.clone();
42 | spawn(async move {
43 | let _ = track.closed().await;
44 | this.inner.remove(&track.inner.info.name);
45 | this.catalog.remove_video(&info);
46 | this.catalog.publish();
47 | });
48 | }
49 |
50 | /// Add an audio track to the broadcast.
51 | pub fn add_audio(&mut self, track: TrackConsumer, info: AudioTrack) {
52 | self.inner.insert(track.inner.clone());
53 | self.catalog.add_audio(info.clone());
54 | self.catalog.publish();
55 |
56 | let mut this = self.clone();
57 | spawn(async move {
58 | let _ = track.closed().await;
59 | this.inner.remove(&track.inner.info.name);
60 | this.catalog.remove_audio(&info);
61 | this.catalog.publish();
62 | });
63 | }
64 |
65 | pub fn create_video(&mut self, video: VideoTrack) -> TrackProducer {
66 | let producer: TrackProducer = video.track.clone().produce().into();
67 | self.add_video(producer.consume(), video);
68 | producer
69 | }
70 |
71 | pub fn create_audio(&mut self, audio: AudioTrack) -> TrackProducer {
72 | let producer: TrackProducer = audio.track.clone().produce().into();
73 | self.add_audio(producer.consume(), audio);
74 | producer
75 | }
76 | }
77 |
78 | impl std::ops::Deref for BroadcastProducer {
79 | type Target = moq_lite::BroadcastProducer;
80 |
81 | fn deref(&self) -> &Self::Target {
82 | &self.inner
83 | }
84 | }
85 |
86 | impl std::ops::DerefMut for BroadcastProducer {
87 | fn deref_mut(&mut self) -> &mut Self::Target {
88 | &mut self.inner
89 | }
90 | }
91 |
92 | /// A wrapper around a moq_lite::BroadcastConsumer that consumes a `catalog.json` track.
93 | #[derive(Clone)]
94 | pub struct BroadcastConsumer {
95 | pub catalog: CatalogConsumer,
96 | pub inner: moq_lite::BroadcastConsumer,
97 | }
98 |
99 | impl BroadcastConsumer {
100 | pub fn new(inner: moq_lite::BroadcastConsumer) -> Self {
101 | let catalog = Track {
102 | name: Catalog::DEFAULT_NAME.to_string(),
103 | priority: 0,
104 | };
105 | let catalog = inner.subscribe(&catalog).into();
106 |
107 | Self { catalog, inner }
108 | }
109 |
110 | pub fn track(&self, track: &Track) -> TrackConsumer {
111 | self.inner.subscribe(track).into()
112 | }
113 | }
114 |
115 | impl std::ops::Deref for BroadcastConsumer {
116 | type Target = moq_lite::BroadcastConsumer;
117 |
118 | fn deref(&self) -> &Self::Target {
119 | &self.inner
120 | }
121 | }
122 |
123 | impl std::ops::DerefMut for BroadcastConsumer {
124 | fn deref_mut(&mut self) -> &mut Self::Target {
125 | &mut self.inner
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/rs/hang/src/cmaf/error.rs:
--------------------------------------------------------------------------------
1 | #[derive(thiserror::Error, Debug)]
2 | pub enum Error {
3 | #[error("moq error: {0}")]
4 | Moq(#[from] moq_lite::Error),
5 |
6 | #[error("mp4 error: {0}")]
7 | Mp4(#[from] mp4_atom::Error),
8 |
9 | #[error("karp error: {0}")]
10 | Karp(#[from] crate::Error),
11 |
12 | #[error("missing tracks")]
13 | MissingTracks,
14 |
15 | #[error("unknown track")]
16 | UnknownTrack,
17 |
18 | #[error("missing box: {0}")]
19 | MissingBox(mp4_atom::FourCC),
20 |
21 | #[error("duplicate box: {0}")]
22 | DuplicateBox(mp4_atom::FourCC),
23 |
24 | #[error("expected box: {0}")]
25 | ExpectedBox(mp4_atom::FourCC),
26 |
27 | #[error("unexpected box: {0}")]
28 | UnexpectedBox(mp4_atom::FourCC),
29 |
30 | #[error("unsupported codec: {0}")]
31 | UnsupportedCodec(String),
32 |
33 | #[error("missing codec")]
34 | MissingCodec,
35 |
36 | #[error("multiple codecs")]
37 | MultipleCodecs,
38 |
39 | #[error("invalid size")]
40 | InvalidSize,
41 |
42 | #[error("empty init")]
43 | EmptyInit,
44 |
45 | #[error("missing init segment")]
46 | MissingInit,
47 |
48 | #[error("multiple init segments")]
49 | MultipleInit,
50 |
51 | #[error("trailing data")]
52 | TrailingData,
53 |
54 | #[error("closed")]
55 | Closed,
56 |
57 | #[error("invalid offset")]
58 | InvalidOffset,
59 |
60 | #[error("unsupported track: {0}")]
61 | UnsupportedTrack(&'static str),
62 |
63 | #[error("io error: {0}")]
64 | Io(#[from] std::io::Error),
65 | }
66 |
67 | pub type Result = std::result::Result;
68 |
--------------------------------------------------------------------------------
/rs/hang/src/cmaf/export.rs:
--------------------------------------------------------------------------------
1 | use super::Result;
2 | use tokio::io::AsyncWrite;
3 |
4 | use crate::{catalog, media};
5 |
6 | // Converts Karp -> fMP4
7 | pub struct Export {
8 | output: W,
9 | broadcast: media::BroadcastConsumer,
10 | }
11 |
12 | // TODO
13 | impl Export {
14 | pub async fn init(input: moq_lite::BroadcastConsumer, output: W) -> Result {
15 | let broadcast = media::BroadcastConsumer::load(input).await?;
16 | Ok(Self { broadcast, output })
17 | }
18 |
19 | pub async fn run(self) -> Result<()> {
20 | todo!();
21 | Ok(())
22 | }
23 |
24 | pub fn catalog(&self) -> &catalog::Broadcast {
25 | self.broadcast.catalog()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/rs/hang/src/cmaf/mod.rs:
--------------------------------------------------------------------------------
1 | mod error;
2 | //mod export;
3 | mod import;
4 |
5 | pub use error::*;
6 | //pub use export::*;
7 | pub use import::*;
8 |
--------------------------------------------------------------------------------
/rs/hang/src/error.rs:
--------------------------------------------------------------------------------
1 | use std::sync::Arc;
2 |
3 | #[derive(Debug, thiserror::Error, Clone)]
4 | pub enum Error {
5 | #[error("transfork error: {0}")]
6 | Moq(#[from] moq_lite::Error),
7 |
8 | #[error("decode error: {0}")]
9 | Decode(#[from] moq_lite::coding::DecodeError),
10 |
11 | #[error("json error: {0}")]
12 | Json(Arc),
13 |
14 | #[error("duplicate track")]
15 | DuplicateTrack,
16 |
17 | #[error("missing track")]
18 | MissingTrack,
19 |
20 | #[error("invalid session ID")]
21 | InvalidSession,
22 |
23 | #[error("empty group")]
24 | EmptyGroup,
25 |
26 | #[error("invalid codec")]
27 | InvalidCodec,
28 |
29 | #[error("unsupported codec")]
30 | UnsupportedCodec,
31 |
32 | #[error("expected int")]
33 | ExpectedInt(#[from] std::num::ParseIntError),
34 |
35 | #[error("hex error: {0}")]
36 | Hex(#[from] hex::FromHexError),
37 | }
38 |
39 | pub type Result = std::result::Result;
40 |
41 | // Wrap in an Arc so it is Clone
42 | impl From for Error {
43 | fn from(err: serde_json::Error) -> Self {
44 | Error::Json(Arc::new(err))
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/rs/hang/src/frame.rs:
--------------------------------------------------------------------------------
1 | use moq_lite::coding::*;
2 |
3 | use derive_more::Debug;
4 |
5 | pub type Timestamp = std::time::Duration;
6 |
7 | #[derive(Clone, Debug)]
8 | pub struct Frame {
9 | pub timestamp: Timestamp,
10 | pub keyframe: bool,
11 |
12 | #[debug("{}", payload.len())]
13 | pub payload: Bytes,
14 | }
15 |
--------------------------------------------------------------------------------
/rs/hang/src/group.rs:
--------------------------------------------------------------------------------
1 | use std::collections::VecDeque;
2 |
3 | use crate::{Frame, Result, Timestamp};
4 | use moq_lite::coding::Decode;
5 |
6 | pub struct GroupConsumer {
7 | // The group.
8 | group: moq_lite::GroupConsumer,
9 |
10 | // The current frame index
11 | index: usize,
12 |
13 | // The any buffered frames in the group.
14 | buffered: VecDeque ,
15 |
16 | // The max timestamp in the group
17 | max_timestamp: Option,
18 | }
19 |
20 | impl GroupConsumer {
21 | pub fn new(group: moq_lite::GroupConsumer) -> Self {
22 | Self {
23 | group,
24 | index: 0,
25 | buffered: VecDeque::new(),
26 | max_timestamp: None,
27 | }
28 | }
29 |
30 | pub async fn read_frame(&mut self) -> Result> {
31 | if let Some(frame) = self.buffered.pop_front() {
32 | Ok(Some(frame))
33 | } else {
34 | self.read_frame_unbuffered().await
35 | }
36 | }
37 |
38 | async fn read_frame_unbuffered(&mut self) -> Result > {
39 | let mut payload = match self.group.read_frame().await? {
40 | Some(payload) => payload,
41 | None => return Ok(None),
42 | };
43 |
44 | let micros = u64::decode(&mut payload)?;
45 | let timestamp = Timestamp::from_micros(micros);
46 |
47 | let frame = Frame {
48 | keyframe: (self.index == 0),
49 | timestamp,
50 | payload,
51 | };
52 |
53 | self.index += 1;
54 | self.max_timestamp = Some(self.max_timestamp.unwrap_or_default().max(timestamp));
55 |
56 | Ok(Some(frame))
57 | }
58 |
59 | // Keep reading and buffering new frames, returning when `max` is larger than or equal to the cutoff.
60 | // Not publish because the API is super weird.
61 | // This will BLOCK FOREVER if the group has ended early; it's intended to be used within select!
62 | pub(super) async fn buffer_frames_until(&mut self, cutoff: Timestamp) -> Timestamp {
63 | loop {
64 | match self.max_timestamp {
65 | Some(timestamp) if timestamp >= cutoff => return timestamp,
66 | _ => (),
67 | }
68 |
69 | match self.read_frame().await {
70 | Ok(Some(frame)) => self.buffered.push_back(frame),
71 | // Otherwise block forever so we don't return from FuturesUnordered
72 | _ => std::future::pending().await,
73 | }
74 | }
75 | }
76 |
77 | pub fn max_timestamp(&self) -> Option {
78 | self.max_timestamp
79 | }
80 | }
81 |
82 | impl std::ops::Deref for GroupConsumer {
83 | type Target = moq_lite::GroupConsumer;
84 |
85 | fn deref(&self) -> &Self::Target {
86 | &self.group
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/rs/hang/src/lib.rs:
--------------------------------------------------------------------------------
1 | mod audio;
2 | mod broadcast;
3 | mod catalog;
4 | mod error;
5 | mod frame;
6 | mod group;
7 | mod room;
8 | mod track;
9 | mod video;
10 |
11 | pub use audio::*;
12 | pub use broadcast::*;
13 | pub use catalog::*;
14 | pub use error::*;
15 | pub use frame::*;
16 | pub use group::*;
17 | pub use room::*;
18 | pub use track::*;
19 | pub use video::*;
20 |
21 | pub mod cmaf;
22 |
23 | // export the moq-lite version in use
24 | pub use moq_lite;
25 |
--------------------------------------------------------------------------------
/rs/hang/src/room.rs:
--------------------------------------------------------------------------------
1 | use std::collections::HashSet;
2 |
3 | use web_async::Lock;
4 |
5 | use crate::{BroadcastConsumer, BroadcastProducer};
6 |
7 | #[derive(Clone)]
8 | pub struct Room {
9 | pub path: String,
10 | broadcasts: moq_lite::OriginConsumer,
11 | session: moq_lite::Session,
12 | ourselves: Lock>,
13 | }
14 |
15 | impl Room {
16 | pub fn new(session: moq_lite::Session, path: String) -> Self {
17 | Self {
18 | broadcasts: session.consume_prefix(&path),
19 | path,
20 | session,
21 | ourselves: Lock::new(HashSet::new()),
22 | }
23 | }
24 |
25 | // Joins the room and returns a producer for the broadcast.
26 | pub fn join(&mut self, name: String) -> BroadcastProducer {
27 | let broadcast = format!("{}{}", self.path, name);
28 | let ourselves = self.ourselves.clone();
29 | ourselves.lock().insert(broadcast.clone());
30 |
31 | let producer = BroadcastProducer::new();
32 | self.session.publish(broadcast, producer.inner.consume());
33 |
34 | let consumer = producer.consume();
35 |
36 | web_async::spawn(async move {
37 | consumer.closed().await;
38 | ourselves.lock().remove(&name);
39 | });
40 |
41 | producer
42 | }
43 |
44 | /// Returns the next broadcaster in the room (not including ourselves).
45 | pub async fn watch(&mut self) -> Option {
46 | loop {
47 | let (prefix, broadcast) = self.broadcasts.next().await?;
48 | if self.ourselves.lock().contains(&prefix) {
49 | continue;
50 | }
51 |
52 | return Some(BroadcastConsumer::new(broadcast));
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/rs/hang/src/video/codec.rs:
--------------------------------------------------------------------------------
1 | use super::*;
2 |
3 | use std::str::FromStr;
4 |
5 | use derive_more::{Display, From};
6 |
7 | use crate::Error;
8 |
9 | #[derive(Debug, Clone, PartialEq, Eq, Display, From)]
10 | pub enum VideoCodec {
11 | H264(H264),
12 | H265(H265),
13 | VP9(VP9),
14 | AV1(AV1),
15 |
16 | #[display("vp8")]
17 | VP8,
18 |
19 | #[display("{_0}")]
20 | Unknown(String),
21 | }
22 |
23 | impl FromStr for VideoCodec {
24 | type Err = Error;
25 |
26 | fn from_str(s: &str) -> Result {
27 | if s.starts_with("avc1.") {
28 | return H264::from_str(s).map(Into::into);
29 | } else if s.starts_with("hvc1.") || s.starts_with("hev1.") {
30 | return H265::from_str(s).map(Into::into);
31 | } else if s == "vp8" {
32 | return Ok(Self::VP8);
33 | } else if s.starts_with("vp09.") {
34 | return VP9::from_str(s).map(Into::into);
35 | } else if s.starts_with("av01.") {
36 | return AV1::from_str(s).map(Into::into);
37 | }
38 |
39 | Ok(Self::Unknown(s.to_string()))
40 | }
41 | }
42 |
43 | #[cfg(test)]
44 | mod test {
45 | use super::*;
46 |
47 | #[test]
48 | fn test_vp8() {
49 | let encoded = "vp8";
50 | let decoded = VideoCodec::from_str(encoded).expect("failed to parse");
51 | assert_eq!(decoded, VideoCodec::VP8);
52 |
53 | let output = decoded.to_string();
54 | assert_eq!(output, encoded);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/rs/hang/src/video/h264.rs:
--------------------------------------------------------------------------------
1 | use std::{fmt, str::FromStr};
2 |
3 | use serde::{Deserialize, Serialize};
4 |
5 | use crate::Error;
6 |
7 | #[serde_with::serde_as]
8 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
9 | pub struct H264 {
10 | pub profile: u8,
11 | pub constraints: u8,
12 | pub level: u8,
13 | }
14 |
15 | impl fmt::Display for H264 {
16 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 | write!(f, "avc1.{:02x}{:02x}{:02x}", self.profile, self.constraints, self.level)
18 | }
19 | }
20 |
21 | impl FromStr for H264 {
22 | type Err = Error;
23 |
24 | fn from_str(s: &str) -> Result {
25 | let mut parts = s.split('.');
26 | if parts.next() != Some("avc1") {
27 | return Err(Error::InvalidCodec);
28 | }
29 |
30 | let part = parts.next().ok_or(Error::InvalidCodec)?;
31 | if part.len() != 6 {
32 | return Err(Error::InvalidCodec);
33 | }
34 |
35 | Ok(Self {
36 | profile: u8::from_str_radix(&part[0..2], 16)?,
37 | constraints: u8::from_str_radix(&part[2..4], 16)?,
38 | level: u8::from_str_radix(&part[4..6], 16)?,
39 | })
40 | }
41 | }
42 |
43 | #[cfg(test)]
44 | mod tests {
45 | use std::str::FromStr;
46 |
47 | use crate::VideoCodec;
48 |
49 | use super::*;
50 |
51 | #[test]
52 | fn test_h264() {
53 | let encoded = "avc1.42c01e";
54 | let decoded = H264 {
55 | profile: 0x42,
56 | constraints: 0xc0,
57 | level: 0x1e,
58 | }
59 | .into();
60 |
61 | let output = VideoCodec::from_str(encoded).expect("failed to parse");
62 | assert_eq!(output, decoded);
63 |
64 | let output = decoded.to_string();
65 | assert_eq!(output, encoded);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/rs/hang/src/video/mod.rs:
--------------------------------------------------------------------------------
1 | mod av1;
2 | mod codec;
3 | mod h264;
4 | mod h265;
5 | mod vp9;
6 |
7 | pub use av1::*;
8 | pub use codec::*;
9 | pub use h264::*;
10 | pub use h265::*;
11 | pub use vp9::*;
12 |
13 | use bytes::Bytes;
14 | use serde::{Deserialize, Serialize};
15 | use serde_with::{hex::Hex, DisplayFromStr};
16 |
17 | use crate::Track;
18 |
19 | #[serde_with::serde_as]
20 | #[serde_with::skip_serializing_none]
21 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
22 | #[serde(rename_all = "camelCase")]
23 | /// Information about a video track.
24 | pub struct VideoTrack {
25 | /// MoQ specific track information
26 | pub track: Track,
27 |
28 | /// The configuration of the video track
29 | pub config: VideoConfig,
30 | }
31 |
32 | #[serde_with::serde_as]
33 | #[serde_with::skip_serializing_none]
34 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
35 | #[serde(rename_all = "camelCase")]
36 | /// VideoDecoderConfig from WebCodecs
37 | /// https://w3c.github.io/webcodecs/#video-decoder-config
38 | pub struct VideoConfig {
39 | /// The codec, see the registry for details:
40 | /// https://w3c.github.io/webcodecs/codec_registry.html
41 | #[serde_as(as = "DisplayFromStr")]
42 | pub codec: VideoCodec,
43 |
44 | /// Information used to initialize the decoder on a per-codec basis.
45 | ///
46 | /// One of the best examples is H264, which needs the sps/pps to function.
47 | /// If not provided, this information is (automatically) inserted before each key-frame (marginally higher overhead).
48 | #[serde(default)]
49 | #[serde_as(as = "Option")]
50 | pub description: Option,
51 |
52 | /// The encoded width/height of the media.
53 | ///
54 | /// This is optional because it can be changed in-band for some codecs.
55 | /// It's primarily a hint to allocate the correct amount of memory up-front.
56 | pub coded_width: Option,
57 | pub coded_height: Option,
58 |
59 | /// The display aspect ratio of the media.
60 | ///
61 | /// This allows you to stretch/shrink pixels of the video.
62 | /// If not provided, the display aspect ratio is 1:1
63 | pub display_ratio_width: Option,
64 | pub display_ratio_height: Option,
65 |
66 | // TODO color space
67 | /// The maximum bitrate of the video track, if known.
68 | #[serde(default)]
69 | pub bitrate: Option,
70 |
71 | /// The frame rate of the video track, if known.
72 | #[serde(default)]
73 | pub framerate: Option,
74 |
75 | /// If true, the decoder will optimize for latency.
76 | ///
77 | /// Default: true
78 | #[serde(default)]
79 | pub optimize_for_latency: Option,
80 |
81 | // The rotation of the video in degrees
82 | // Default: 0
83 | #[serde(default)]
84 | pub rotation: Option,
85 |
86 | // If true, the decoder will flip the video horizontally
87 | // Default: false
88 | #[serde(default)]
89 | pub flip: Option,
90 | }
91 |
--------------------------------------------------------------------------------
/rs/hang/src/video/vp9.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 |
3 | use crate::Error;
4 |
5 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
6 | pub struct VP9 {
7 | pub profile: u8,
8 | pub level: u8,
9 | pub bit_depth: u8,
10 | pub chroma_subsampling: u8,
11 | pub color_primaries: u8,
12 | pub transfer_characteristics: u8,
13 | pub matrix_coefficients: u8,
14 | pub full_range: bool,
15 | }
16 |
17 | // vp09.....
18 | // ...
19 | impl std::fmt::Display for VP9 {
20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 | write!(f, "vp09.{:02}.{:02}.{:02}", self.profile, self.level, self.bit_depth)?;
22 |
23 | let short = VP9 {
24 | profile: self.profile,
25 | level: self.level,
26 | bit_depth: self.bit_depth,
27 | ..Default::default()
28 | };
29 |
30 | if self == &short {
31 | return Ok(());
32 | }
33 |
34 | write!(
35 | f,
36 | ".{:02}.{:02}.{:02}.{:02}.{:02}",
37 | self.chroma_subsampling,
38 | self.color_primaries,
39 | self.transfer_characteristics,
40 | self.matrix_coefficients,
41 | self.full_range as u8,
42 | )
43 | }
44 | }
45 |
46 | impl std::str::FromStr for VP9 {
47 | type Err = Error;
48 |
49 | fn from_str(s: &str) -> Result {
50 | let parts = s
51 | .strip_prefix("vp09.")
52 | .ok_or(Error::InvalidCodec)?
53 | .split('.')
54 | .map(u8::from_str)
55 | .collect::, _>>()?;
56 |
57 | if parts.len() < 3 {
58 | return Err(Error::InvalidCodec);
59 | }
60 |
61 | let mut vp9 = VP9 {
62 | profile: parts[0],
63 | level: parts[1],
64 | bit_depth: parts[2],
65 | ..Default::default()
66 | };
67 |
68 | if parts.len() == 3 {
69 | return Ok(vp9);
70 | } else if parts.len() != 8 {
71 | return Err(Error::InvalidCodec);
72 | }
73 |
74 | vp9.chroma_subsampling = parts[3];
75 | vp9.color_primaries = parts[4];
76 | vp9.transfer_characteristics = parts[5];
77 | vp9.matrix_coefficients = parts[6];
78 | vp9.full_range = parts[7] == 1;
79 |
80 | Ok(vp9)
81 | }
82 | }
83 |
84 | impl Default for VP9 {
85 | fn default() -> Self {
86 | Self {
87 | profile: 0,
88 | level: 0,
89 | bit_depth: 0,
90 | chroma_subsampling: 1,
91 | color_primaries: 1,
92 | transfer_characteristics: 1,
93 | matrix_coefficients: 1,
94 | full_range: false,
95 | }
96 | }
97 | }
98 |
99 | #[cfg(test)]
100 | mod test {
101 | use std::str::FromStr;
102 |
103 | use crate::VideoCodec;
104 |
105 | use super::*;
106 |
107 | #[test]
108 | fn test_vp9() {
109 | let encoded = "vp09.02.10.10.01.09.16.09.01";
110 | let decoded = VP9 {
111 | profile: 2,
112 | level: 10,
113 | bit_depth: 10,
114 | chroma_subsampling: 1,
115 | color_primaries: 9,
116 | transfer_characteristics: 16,
117 | matrix_coefficients: 9,
118 | full_range: true,
119 | }
120 | .into();
121 |
122 | let output = VideoCodec::from_str(encoded).expect("failed to parse");
123 | assert_eq!(output, decoded);
124 |
125 | let output = decoded.to_string();
126 | assert_eq!(output, encoded);
127 | }
128 |
129 | #[test]
130 | fn test_vp9_short() {
131 | let encoded = "vp09.00.41.08";
132 | let decoded = VP9 {
133 | profile: 0,
134 | level: 41,
135 | bit_depth: 8,
136 | ..Default::default()
137 | }
138 | .into();
139 |
140 | let output = VideoCodec::from_str(encoded).expect("failed to parse");
141 | assert_eq!(output, decoded);
142 |
143 | let output = decoded.to_string();
144 | assert_eq!(output, encoded);
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/rs/moq-clock/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "moq-clock"
3 | description = "CLOCK over QUIC"
4 | authors = ["Luke Curley"]
5 | repository = "https://github.com/kixelated/moq"
6 | license = "MIT OR Apache-2.0"
7 |
8 | version = "0.6.0"
9 | edition = "2021"
10 |
11 | keywords = ["quic", "http3", "webtransport", "media", "live"]
12 | categories = ["multimedia", "network-programming", "web-programming"]
13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
14 |
15 | [dependencies]
16 | anyhow = { version = "1", features = ["backtrace"] }
17 | chrono = "0.4"
18 | clap = { version = "4", features = ["derive"] }
19 | moq-lite = { workspace = true }
20 | moq-native = { workspace = true }
21 | tokio = { workspace = true, features = ["full"] }
22 | tracing = "0.1"
23 | url = "2"
24 |
--------------------------------------------------------------------------------
/rs/moq-clock/src/clock.rs:
--------------------------------------------------------------------------------
1 | use anyhow::Context;
2 |
3 | use chrono::prelude::*;
4 | use moq_lite::*;
5 |
6 | pub struct Publisher {
7 | track: TrackProducer,
8 | }
9 |
10 | impl Publisher {
11 | pub fn new(track: TrackProducer) -> Self {
12 | Self { track }
13 | }
14 |
15 | pub async fn run(mut self) -> anyhow::Result<()> {
16 | let start = Utc::now();
17 | let mut now = start;
18 |
19 | // Just for fun, don't start at zero.
20 | let mut sequence = start.minute();
21 |
22 | loop {
23 | let segment = self.track.create_group(sequence.into()).unwrap();
24 |
25 | sequence += 1;
26 |
27 | tokio::spawn(async move {
28 | if let Err(err) = Self::send_segment(segment, now).await {
29 | tracing::warn!("failed to send minute: {:?}", err);
30 | }
31 | });
32 |
33 | let next = now + chrono::Duration::try_minutes(1).unwrap();
34 | let next = next.with_second(0).unwrap().with_nanosecond(0).unwrap();
35 |
36 | let delay = (next - now).to_std().unwrap();
37 | tokio::time::sleep(delay).await;
38 |
39 | now = next; // just assume we didn't undersleep
40 | }
41 | }
42 |
43 | async fn send_segment(mut segment: GroupProducer, mut now: DateTime) -> anyhow::Result<()> {
44 | // Everything but the second.
45 | let base = now.format("%Y-%m-%d %H:%M:").to_string();
46 |
47 | segment.write_frame(base.clone());
48 |
49 | loop {
50 | let delta = now.format("%S").to_string();
51 | segment.write_frame(delta.clone());
52 |
53 | let next = now + chrono::Duration::try_seconds(1).unwrap();
54 | let next = next.with_nanosecond(0).unwrap();
55 |
56 | let delay = (next - now).to_std().unwrap();
57 | tokio::time::sleep(delay).await;
58 |
59 | // Get the current time again to check if we overslept
60 | let next = Utc::now();
61 | if next.minute() != now.minute() {
62 | break;
63 | }
64 |
65 | now = next;
66 | }
67 |
68 | segment.finish();
69 |
70 | Ok(())
71 | }
72 | }
73 | pub struct Subscriber {
74 | track: TrackConsumer,
75 | }
76 |
77 | impl Subscriber {
78 | pub fn new(track: TrackConsumer) -> Self {
79 | Self { track }
80 | }
81 |
82 | pub async fn run(mut self) -> anyhow::Result<()> {
83 | while let Some(mut group) = self.track.next_group().await? {
84 | let base = group
85 | .read_frame()
86 | .await
87 | .context("failed to get first object")?
88 | .context("empty group")?;
89 |
90 | let base = String::from_utf8_lossy(&base);
91 |
92 | while let Some(object) = group.read_frame().await? {
93 | let str = String::from_utf8_lossy(&object);
94 | println!("{}{}", base, str);
95 | }
96 | }
97 |
98 | Ok(())
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/rs/moq-clock/src/main.rs:
--------------------------------------------------------------------------------
1 | use moq_native::quic;
2 | use std::net;
3 | use url::Url;
4 |
5 | use clap::Parser;
6 |
7 | mod clock;
8 | use moq_lite::*;
9 |
10 | #[derive(Parser, Clone)]
11 | pub struct Config {
12 | /// Listen for UDP packets on the given address.
13 | #[arg(long, default_value = "[::]:0")]
14 | pub bind: net::SocketAddr,
15 |
16 | /// Connect to the given URL starting with https://
17 | #[arg()]
18 | pub url: Url,
19 |
20 | /// The TLS configuration.
21 | #[command(flatten)]
22 | pub tls: moq_native::tls::Args,
23 |
24 | /// The path of the clock broadcast.
25 | #[arg(long, default_value = "clock")]
26 | pub broadcast: String,
27 |
28 | /// The name of the clock track.
29 | #[arg(long, default_value = "seconds")]
30 | pub track: String,
31 |
32 | /// The log configuration.
33 | #[command(flatten)]
34 | pub log: moq_native::log::Args,
35 |
36 | /// Whether to publish the clock or consume it.
37 | #[command(subcommand)]
38 | pub role: Command,
39 | }
40 |
41 | #[derive(Parser, Clone)]
42 | pub enum Command {
43 | Publish,
44 | Subscribe,
45 | }
46 |
47 | #[tokio::main]
48 | async fn main() -> anyhow::Result<()> {
49 | let config = Config::parse();
50 | config.log.init();
51 |
52 | let tls = config.tls.load()?;
53 |
54 | let quic = quic::Endpoint::new(quic::Config { bind: config.bind, tls })?;
55 |
56 | tracing::info!(url = ?config.url, "connecting to server");
57 |
58 | let session = quic.client.connect(config.url).await?;
59 | let mut session = moq_lite::Session::connect(session).await?;
60 |
61 | let track = Track {
62 | name: config.track,
63 | priority: 0,
64 | };
65 |
66 | match config.role {
67 | Command::Publish => {
68 | let broadcast = BroadcastProducer::new();
69 | let track = broadcast.create(track);
70 | let clock = clock::Publisher::new(track);
71 |
72 | session.publish(config.broadcast, broadcast.consume());
73 | clock.run().await
74 | }
75 | Command::Subscribe => {
76 | let broadcast = session.consume(&config.broadcast);
77 | let track = broadcast.subscribe(&track);
78 | let clock = clock::Subscriber::new(track);
79 |
80 | clock.run().await
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/rs/moq-native/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "moq-native"
3 | description = "Media over QUIC - Helper library for native applications"
4 | authors = ["Luke Curley"]
5 | repository = "https://github.com/kixelated/moq"
6 | license = "MIT OR Apache-2.0"
7 |
8 | version = "0.6.9"
9 | edition = "2021"
10 |
11 | keywords = ["quic", "http3", "webtransport", "media", "live"]
12 | categories = ["multimedia", "network-programming", "web-programming"]
13 |
14 | [dependencies]
15 | anyhow = { version = "1", features = ["backtrace"] }
16 | clap = { version = "4", features = ["derive"] }
17 | futures = "0.3"
18 | hex = "0.4"
19 | moq-lite = { workspace = true }
20 | quinn = "0.11"
21 | rcgen = "0.13"
22 | reqwest = { version = "0.12", default-features = false }
23 | ring = "0.17"
24 | rustls = "0.23"
25 | rustls-native-certs = "0.8"
26 | rustls-pemfile = "2"
27 | time = "0.3"
28 | tokio = { workspace = true, features = ["full"] }
29 | tracing = "0.1"
30 | tracing-subscriber = { version = "0.3", features = ["env-filter"] }
31 | url = "2"
32 | web-transport = { workspace = true }
33 | webpki = "0.22"
34 |
--------------------------------------------------------------------------------
/rs/moq-native/src/lib.rs:
--------------------------------------------------------------------------------
1 | pub mod log;
2 | pub mod quic;
3 | pub mod tls;
4 |
5 | // Re-export these crates.
6 | pub use moq_lite;
7 | pub use web_transport;
8 |
--------------------------------------------------------------------------------
/rs/moq-native/src/log.rs:
--------------------------------------------------------------------------------
1 | use clap::Parser;
2 | use tracing::level_filters::LevelFilter;
3 | use tracing_subscriber::EnvFilter;
4 |
5 | #[derive(Parser, Clone, Default)]
6 | pub struct Args {
7 | #[arg(long, short, action = clap::ArgAction::Count)]
8 | pub verbose: u8,
9 |
10 | #[arg(long, short, action = clap::ArgAction::Count, conflicts_with = "verbose")]
11 | pub quiet: u8,
12 | }
13 |
14 | impl Args {
15 | pub fn level(&self) -> LevelFilter {
16 | // Default to INFO, go up or down based on -q or -v counts
17 | match self.verbose {
18 | 0 => match self.quiet {
19 | 0 => LevelFilter::INFO,
20 | 1 => LevelFilter::ERROR,
21 | _ => LevelFilter::OFF,
22 | },
23 | 1 => LevelFilter::DEBUG,
24 | _ => LevelFilter::TRACE,
25 | }
26 | }
27 |
28 | pub fn init(&self) {
29 | let filter = EnvFilter::builder()
30 | .with_default_directive(self.level().into()) // Default to our -q/-v args
31 | .from_env_lossy() // Allow overriding with RUST_LOG
32 | .add_directive("h2=warn".parse().unwrap())
33 | .add_directive("quinn=info".parse().unwrap())
34 | .add_directive("tracing::span=off".parse().unwrap())
35 | .add_directive("tracing::span::active=off".parse().unwrap());
36 |
37 | let logger = tracing_subscriber::FmtSubscriber::builder()
38 | .with_writer(std::io::stderr)
39 | .with_env_filter(filter)
40 | .finish();
41 |
42 | tracing::subscriber::set_global_default(logger).unwrap();
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/rs/moq-relay/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "moq-relay"
3 | description = "Media over QUIC"
4 | authors = ["Luke Curley"]
5 | repository = "https://github.com/kixelated/moq"
6 | license = "MIT OR Apache-2.0"
7 |
8 | version = "0.7.0"
9 | edition = "2021"
10 |
11 | keywords = ["quic", "http3", "webtransport", "media", "live"]
12 | categories = ["multimedia", "network-programming", "web-programming"]
13 |
14 | [dependencies]
15 | anyhow = { version = "1", features = ["backtrace"] }
16 | axum = { version = "0.8", features = ["tokio"] }
17 | bytes = "1"
18 | clap = { version = "4", features = ["derive"] }
19 | futures = "0.3"
20 | http-body = "1"
21 | hyper-serve = { version = "0.6", features = [
22 | "tls-rustls",
23 | ] } # fork of axum-server
24 | moq-lite = { workspace = true }
25 | moq-native = { workspace = true }
26 | thiserror = "2"
27 | tokio = { workspace = true, features = ["full"] }
28 | tower-http = { version = "0.6", features = ["cors"] }
29 | tracing = "0.1"
30 | url = "2"
31 | web-transport = { workspace = true }
32 |
--------------------------------------------------------------------------------
/rs/moq-relay/README.md:
--------------------------------------------------------------------------------
1 | # moq-relay
2 |
3 | **moq-relay** is a server that forwards subscriptions from publishers to subscribers, caching and deduplicating along the way.
4 | It's designed to be run in a datacenter, relaying media across multiple hops to deduplicate and improve QoS.
5 |
6 | Required arguments:
7 |
8 | - `--bind `: Listen on this address, default: `[::]:4443`
9 | - `--tls-cert `: Use the certificate file at this path
10 | - `--tls-key ` Use the private key at this path
11 |
12 | This listens for WebTransport connections on `UDP https://localhost:4443` by default.
13 | You need a client to connect to that address, to both publish and consume media.
14 |
15 | ## HTTP
16 | Primarily for debugging, you can also connect to the relay via HTTP.
17 |
18 | - `GET /certificate.sha256`: Returns the fingerprint of the TLS certificate.
19 | - `GET /announced/*prefix`: Returns all of the announced tracks with the given (optional) prefix.
20 | - `GET /fetch/*path`: Returns the latest group of the given track.
21 |
22 | The HTTP server listens on the same bind address, but TCP instead of UDP.
23 | The default is `http://localhost:4443`.
24 | HTTPS is currently not supported.
25 |
26 | ## Clustering
27 | In order to scale MoQ, you will eventually need to run multiple moq-relay instances potentially in different regions.
28 | This is called *clustering*, where the goal is that a user connects to the closest relay and they magically form a mesh behind the scenes.
29 |
30 | **moq-relay** uses a simple clustering scheme using moq-lite.
31 | This is both dog-fooding and a surprisingly ueeful way to distribute live metadata at scale.
32 |
33 | We currently use a single "root" node that is used to discover members of the cluster and what broadcasts they offer.
34 | This is a normal moq-relay instance, potentially serving public traffic, unaware of the fact that it's in charge of other relays.
35 |
36 | The other moq-relay instances accept internet traffic and consult the root for routing.
37 | They can then advertise their internal ip/hostname to other instances when publishing a broadcast.
38 |
39 | Cluster arguments:
40 |
41 | - `--cluster-root `: The hostname/ip of the root node. If missing, this node is a root.
42 | - `--cluster-node `: The hostname/ip of this instance. There needs to be a corresponding valid TLS certificate, potentially self-signed. If missing, published broadcasts will only be available on this specific relay.
43 |
44 | ## Authentication
45 | There is currently no authentication.
46 | All broadcasts are public and discoverable.
47 |
48 | However, track names are *not* public.
49 | An application could make them unguessable in order to implement private broadcasts.
50 |
51 | If security/privacy is a concern, you should encrypt all application payloads anyway (ex. via MLS).
52 | moq-relay will **only** use the limited header information surfaced in the moq-lite layer.
53 |
--------------------------------------------------------------------------------
/rs/moq-relay/src/connection.rs:
--------------------------------------------------------------------------------
1 | use crate::Cluster;
2 |
3 | pub struct Connection {
4 | id: u64,
5 | session: web_transport::Session,
6 | cluster: Cluster,
7 | path: String,
8 | }
9 |
10 | impl Connection {
11 | pub fn new(id: u64, session: web_transport::Session, cluster: Cluster) -> Self {
12 | // Scope everything to the session URL path.
13 | // ex. if somebody connects with `/foo/bar/` then SUBSCRIBE "baz" will return `/foo/bar/baz`.
14 | // TODO sign this path so it can't be modified by an unauthenticated user.
15 | let path = session.url().path().strip_prefix("/").unwrap_or("").to_string();
16 |
17 | Self {
18 | id,
19 | session,
20 | cluster,
21 | path,
22 | }
23 | }
24 |
25 | #[tracing::instrument("session", skip_all, fields(id = self.id, path = self.path))]
26 | pub async fn run(mut self) {
27 | let mut session = match moq_lite::Session::accept(self.session).await {
28 | Ok(session) => session,
29 | Err(err) => {
30 | tracing::warn!(?err, "failed to accept session");
31 | return;
32 | }
33 | };
34 |
35 | // Publish all local and remote broadcasts to the session.
36 | // TODO We need to learn if this is a relay and NOT publish remotes.
37 | let locals = self.cluster.locals.consume_prefix(&self.path);
38 | let remotes = self.cluster.remotes.consume_prefix(&self.path);
39 | session.publish_all(locals);
40 | session.publish_all(remotes);
41 |
42 | // Publish all broadcasts produced by the session to the local origin.
43 | // TODO These need to be published to remotes if it's a relay.
44 | let produced = session.consume_all();
45 | self.cluster.locals.publish_prefix(&self.path, produced);
46 |
47 | // Wait until the session is closed.
48 | session.closed().await;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/rs/moq-relay/src/main.rs:
--------------------------------------------------------------------------------
1 | mod cluster;
2 | mod connection;
3 | mod web;
4 |
5 | pub use cluster::*;
6 | pub use connection::*;
7 | pub use web::*;
8 |
9 | use anyhow::Context;
10 | use clap::Parser;
11 | use moq_native::quic;
12 |
13 | #[derive(Parser, Clone)]
14 | pub struct Config {
15 | /// Listen on this address, both TCP and UDP.
16 | #[arg(long, default_value = "[::]:443")]
17 | pub bind: String,
18 |
19 | /// The TLS configuration.
20 | #[command(flatten)]
21 | pub tls: moq_native::tls::Args,
22 |
23 | /// Log configuration.
24 | #[command(flatten)]
25 | pub log: moq_native::log::Args,
26 |
27 | /// Cluster configuration.
28 | #[command(flatten)]
29 | pub cluster: ClusterConfig,
30 | }
31 |
32 | #[tokio::main]
33 | async fn main() -> anyhow::Result<()> {
34 | let config = Config::parse();
35 | config.log.init();
36 |
37 | let bind = tokio::net::lookup_host(config.bind)
38 | .await
39 | .context("invalid bind address")?
40 | .next()
41 | .context("invalid bind address")?;
42 |
43 | let tls = config.tls.load()?;
44 | if tls.server.is_none() {
45 | anyhow::bail!("missing TLS certificates");
46 | }
47 |
48 | let quic = quic::Endpoint::new(quic::Config { bind, tls: tls.clone() })?;
49 | let mut server = quic.server.context("missing TLS certificate")?;
50 |
51 | let cluster = Cluster::new(config.cluster.clone(), quic.client);
52 | let cloned = cluster.clone();
53 | tokio::spawn(async move { cloned.run().await.expect("cluster failed") });
54 |
55 | // Create a web server too.
56 | let web = Web::new(WebConfig {
57 | bind,
58 | tls,
59 | cluster: cluster.clone(),
60 | });
61 |
62 | tokio::spawn(async move {
63 | web.run().await.expect("failed to run web server");
64 | });
65 |
66 | tracing::info!(addr = %bind, "listening");
67 |
68 | let mut conn_id = 0;
69 |
70 | while let Some(conn) = server.accept().await {
71 | let session = Connection::new(conn_id, conn.into(), cluster.clone());
72 | conn_id += 1;
73 | tokio::spawn(session.run());
74 | }
75 |
76 | Ok(())
77 | }
78 |
--------------------------------------------------------------------------------
/rs/moq/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.2.0](https://github.com/kixelated/moq/compare/moq-lite-v0.1.0...moq-lite-v0.2.0) - 2025-05-21
11 |
12 | ### Other
13 |
14 | - Split into Rust/Javascript halves and rebrand as moq-lite/hang ([#376](https://github.com/kixelated/moq/pull/376))
15 |
--------------------------------------------------------------------------------
/rs/moq/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "moq-lite"
3 | description = "Media over QUIC - Transport (Lite)"
4 | authors = ["Luke Curley"]
5 | repository = "https://github.com/kixelated/moq"
6 | license = "MIT OR Apache-2.0"
7 |
8 | version = "0.2.0"
9 | edition = "2021"
10 |
11 | keywords = ["quic", "http3", "webtransport", "media", "live"]
12 | categories = ["multimedia", "network-programming", "web-programming"]
13 |
14 | [features]
15 | serde = ["dep:serde"]
16 |
17 | [dependencies]
18 | async-channel = "2"
19 | bytes = "1"
20 | futures = "0.3"
21 | num_enum = "0.7"
22 | serde = { workspace = true, optional = true }
23 | thiserror = "2"
24 | tokio = { workspace = true, features = [
25 | "macros",
26 | "io-util",
27 | "sync",
28 | "test-util",
29 | ] }
30 | tracing = "0.1"
31 | web-async = { workspace = true }
32 | web-transport = { workspace = true }
33 |
--------------------------------------------------------------------------------
/rs/moq/README.md:
--------------------------------------------------------------------------------
1 | [](https://docs.rs/moq-lite/)
2 | [](https://crates.io/crates/moq-lite)
3 | [](LICENSE-MIT)
4 |
5 | # moq-lite
6 |
7 | A Rust implementation of (a fork of) the proposed IETF standard.
8 |
9 | [Specification](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/)
10 | [Github](https://github.com/kixelated/moq-drafts)
11 |
--------------------------------------------------------------------------------
/rs/moq/src/coding/decode.rs:
--------------------------------------------------------------------------------
1 | use std::string::FromUtf8Error;
2 | use thiserror::Error;
3 |
4 | pub trait Decode: Sized {
5 | fn decode(buf: &mut B) -> Result;
6 | }
7 |
8 | /// A decode error.
9 | #[derive(Error, Debug, Clone)]
10 | pub enum DecodeError {
11 | #[error("short buffer")]
12 | Short,
13 |
14 | #[error("invalid string")]
15 | InvalidString(#[from] FromUtf8Error),
16 |
17 | #[error("invalid message: {0:?}")]
18 | InvalidMessage(u64),
19 |
20 | #[error("invalid role: {0:?}")]
21 | InvalidRole(u64),
22 |
23 | #[error("invalid subscribe location")]
24 | InvalidSubscribeLocation,
25 |
26 | #[error("invalid value")]
27 | InvalidValue,
28 |
29 | #[error("bounds exceeded")]
30 | BoundsExceeded,
31 |
32 | #[error("expected end")]
33 | ExpectedEnd,
34 |
35 | #[error("expected data")]
36 | ExpectedData,
37 |
38 | // TODO move these to ParamError
39 | #[error("duplicate parameter")]
40 | DupliateParameter,
41 |
42 | #[error("missing parameter")]
43 | MissingParameter,
44 |
45 | #[error("invalid parameter")]
46 | InvalidParameter,
47 | }
48 |
49 | impl Decode for u8 {
50 | fn decode(r: &mut R) -> Result {
51 | match r.has_remaining() {
52 | true => Ok(r.get_u8()),
53 | false => Err(DecodeError::Short),
54 | }
55 | }
56 | }
57 |
58 | impl Decode for String {
59 | /// Decode a string with a varint length prefix.
60 | fn decode(r: &mut R) -> Result {
61 | let v = Vec::::decode(r)?;
62 | let str = String::from_utf8(v)?;
63 |
64 | Ok(str)
65 | }
66 | }
67 |
68 | impl Decode for Vec {
69 | fn decode(buf: &mut B) -> Result {
70 | let size = usize::decode(buf)?;
71 |
72 | // Don't allocate more than 1024 elements upfront
73 | let mut v = Vec::with_capacity(size.min(1024));
74 |
75 | for _ in 0..size {
76 | v.push(T::decode(buf)?);
77 | }
78 |
79 | Ok(v)
80 | }
81 | }
82 |
83 | impl Decode for std::time::Duration {
84 | fn decode(buf: &mut B) -> Result {
85 | let ms = u64::decode(buf)?;
86 | Ok(std::time::Duration::from_micros(ms))
87 | }
88 | }
89 |
90 | impl Decode for i8 {
91 | fn decode(r: &mut R) -> Result {
92 | if !r.has_remaining() {
93 | return Err(DecodeError::Short);
94 | }
95 |
96 | // This is not the usual way of encoding negative numbers.
97 | // i8 doesn't exist in the draft, but we use it instead of u8 for priority.
98 | // A default of 0 is more ergonomic for the user than a default of 128.
99 | Ok(((r.get_u8() as i16) - 128) as i8)
100 | }
101 | }
102 |
103 | impl Decode for bytes::Bytes {
104 | fn decode(r: &mut R) -> Result {
105 | let len = usize::decode(r)?;
106 | if r.remaining() < len {
107 | return Err(DecodeError::Short);
108 | }
109 | let bytes = r.copy_to_bytes(len);
110 | Ok(bytes)
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/rs/moq/src/coding/encode.rs:
--------------------------------------------------------------------------------
1 | use std::sync::Arc;
2 |
3 | use super::Sizer;
4 |
5 | pub trait Encode: Sized {
6 | // Encode the value to the given writer.
7 | // This will panic if the Buf is not large enough; use a Vec or encode_size() to check.
8 | fn encode(&self, w: &mut W);
9 |
10 | // Return the size of the encoded value
11 | // Implementations can override this to provide a more efficient implementation
12 | fn encode_size(&self) -> usize {
13 | let mut sizer = Sizer::default();
14 | self.encode(&mut sizer);
15 | sizer.size
16 | }
17 | }
18 |
19 | impl Encode for u8 {
20 | fn encode(&self, w: &mut W) {
21 | w.put_u8(*self);
22 | }
23 |
24 | fn encode_size(&self) -> usize {
25 | 1
26 | }
27 | }
28 |
29 | impl Encode for String {
30 | fn encode(&self, w: &mut W) {
31 | self.as_str().encode(w)
32 | }
33 | }
34 |
35 | impl Encode for &str {
36 | fn encode(&self, w: &mut W) {
37 | self.len().encode(w);
38 | w.put(self.as_bytes());
39 | }
40 | }
41 |
42 | impl Encode for std::time::Duration {
43 | fn encode(&self, w: &mut W) {
44 | let v: u64 = self.as_micros().try_into().expect("duration too large");
45 | v.encode(w);
46 | }
47 | }
48 |
49 | impl Encode for i8 {
50 | fn encode(&self, w: &mut W) {
51 | // This is not the usual way of encoding negative numbers.
52 | // i8 doesn't exist in the draft, but we use it instead of u8 for priority.
53 | // A default of 0 is more ergonomic for the user than a default of 128.
54 | w.put_u8(((*self as i16) + 128) as u8);
55 | }
56 |
57 | fn encode_size(&self) -> usize {
58 | 1
59 | }
60 | }
61 |
62 | impl Encode for &[T] {
63 | fn encode(&self, w: &mut W) {
64 | self.len().encode(w);
65 | for item in self.iter() {
66 | item.encode(w);
67 | }
68 | }
69 | }
70 |
71 | impl Encode for Vec {
72 | fn encode(&self, w: &mut W) {
73 | self.len().encode(w);
74 | for item in self.iter() {
75 | item.encode(w);
76 | }
77 | }
78 | }
79 |
80 | impl Encode for bytes::Bytes {
81 | fn encode(&self, w: &mut W) {
82 | self.len().encode(w);
83 | w.put_slice(self);
84 | }
85 |
86 | fn encode_size(&self) -> usize {
87 | self.len().encode_size() + self.len()
88 | }
89 | }
90 |
91 | impl Encode for Arc {
92 | fn encode(&self, w: &mut W) {
93 | (**self).encode(w);
94 | }
95 |
96 | fn encode_size(&self) -> usize {
97 | (**self).encode_size()
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/rs/moq/src/coding/mod.rs:
--------------------------------------------------------------------------------
1 | //! This module contains encoding and decoding helpers.
2 |
3 | mod decode;
4 | mod encode;
5 | mod size;
6 | mod varint;
7 |
8 | pub use decode::*;
9 | pub use encode::*;
10 | pub use size::*;
11 | pub use varint::*;
12 |
13 | // Re-export the bytes crate
14 | pub use bytes::*;
15 |
--------------------------------------------------------------------------------
/rs/moq/src/error.rs:
--------------------------------------------------------------------------------
1 | use crate::{coding, message};
2 |
3 | /// A list of possible errors that can occur during the session.
4 | #[derive(thiserror::Error, Debug, Clone)]
5 | pub enum Error {
6 | #[error("webtransport error: {0}")]
7 | WebTransport(#[from] web_transport::Error),
8 |
9 | #[error("decode error: {0}")]
10 | Decode(#[from] coding::DecodeError),
11 |
12 | // TODO move to a ConnectError
13 | #[error("unsupported versions: client={0:?} server={1:?}")]
14 | Version(message::Versions, message::Versions),
15 |
16 | /// A required extension was not present
17 | #[error("extension required: {0}")]
18 | RequiredExtension(u64),
19 |
20 | /// An unexpected stream was received
21 | #[error("unexpected stream: {0:?}")]
22 | UnexpectedStream(message::ControlType),
23 |
24 | /// Some VarInt was too large and we were too lazy to handle it
25 | #[error("varint bounds exceeded")]
26 | BoundsExceeded(#[from] coding::BoundsExceeded),
27 |
28 | /// A duplicate ID was used
29 | // The broadcast/track is a duplicate
30 | #[error("duplicate")]
31 | Duplicate,
32 |
33 | // Cancel is returned when there are no more readers.
34 | #[error("cancelled")]
35 | Cancel,
36 |
37 | /// It took too long to open or transmit a stream.
38 | #[error("timeout")]
39 | Timeout,
40 |
41 | /// The group is older than the latest group and dropped.
42 | #[error("old")]
43 | Old,
44 |
45 | // The application closes the stream with a code.
46 | #[error("app code={0}")]
47 | App(u32),
48 |
49 | #[error("not found")]
50 | NotFound,
51 |
52 | #[error("wrong frame size")]
53 | WrongSize,
54 |
55 | #[error("protocol violation")]
56 | ProtocolViolation,
57 | }
58 |
59 | impl Error {
60 | /// An integer code that is sent over the wire.
61 | pub fn to_code(&self) -> u32 {
62 | match self {
63 | Self::Cancel => 0,
64 | Self::RequiredExtension(_) => 1,
65 | Self::Old => 2,
66 | Self::Timeout => 3,
67 | Self::WebTransport(_) => 4,
68 | Self::Decode(_) => 5,
69 | Self::Version(..) => 9,
70 | Self::UnexpectedStream(_) => 10,
71 | Self::BoundsExceeded(_) => 11,
72 | Self::Duplicate => 12,
73 | Self::NotFound => 13,
74 | Self::WrongSize => 14,
75 | Self::ProtocolViolation => 15,
76 | Self::App(app) => *app + 64,
77 | }
78 | }
79 | }
80 |
81 | pub type Result = std::result::Result;
82 |
--------------------------------------------------------------------------------
/rs/moq/src/lib.rs:
--------------------------------------------------------------------------------
1 | //! An fork of the MoQ Transport protocol.
2 | //!
3 | //! MoQ Transfork is a pub/sub protocol over QUIC.
4 | //! While originally designed for live media, MoQ Transfork is generic and can be used for other live applications.
5 | //! The specification is a work in progress and will change.
6 | //! See the [specification](https://datatracker.ietf.org/doc/draft-lcurley-moq-lite/) and [github](https://github.com/kixelated/moq-drafts) for any updates.
7 | //!
8 | //! The core of this crate is [Session], established with [Session::connect] (client) or [Session::accept] (server).
9 | //! Once you have a session, you can [Session::publish] or [Session::subscribe].
10 | //!
11 | //! # Producing
12 | //! There can be only 1 publisher.
13 | //!
14 | //! - [BroadcastProducer] can create any number of [TrackProducer]s. Each [Track] is produced independently with a specified order/priority.
15 | //! - [TrackProducer] can append any number of [GroupProducer]s, with new subscribers joining at [Group] boundaries (ex. keyframes).
16 | //! - [GroupProducer] can append any number of [Frame]s, either using [GroupProducer::write_frame] (contiguous) or [GroupProducer::create_frame] (chunked).
17 | //! - [FrameProducer] is thus optional, allowing you to specify an upfront size to write multiple chunks.
18 | //!
19 | //! All methods are synchronous and will NOT block.
20 | //! If there are no subscribers, then no data will flow over the network but it will remain in cache.
21 | //! If the session is dropped, then any future writes will error.
22 | //!
23 | //! # Consuming
24 | //! There can be N consumers (via [Clone]), each getting a copy of any requested data.
25 | //!
26 | //! - [BroadcastConsumer] can fetch any number of [TrackConsumer]s. Each [Track] is consumed independently with a specified order/priority.
27 | //! - [TrackConsumer] can fetch any number of [GroupConsumer]s, joining at a [Group] boundary (ex. keyframes).
28 | //! - [GroupConsumer] can fetch any number of [Frame]s, either using [GroupConsumer::read_frame] (contiguous) or [GroupConsumer::next_frame] (chunked).
29 | //! - [FrameConsumer] is thus optional, allowing you to read chunks as they arrive.
30 | //!
31 | //! All methods are asynchronous and will block until data is available.
32 | //! If the publisher disconnects, then the consumer will error.
33 | //! If the publisher is dropped (clean FIN), then the above methods will return [None].
34 | //!
35 | mod error;
36 | mod model;
37 | mod session;
38 |
39 | pub mod coding;
40 | pub mod message;
41 | pub use error::*;
42 | pub use model::*;
43 | pub use session::*;
44 |
45 | /// The ALPN used when connecting via QUIC directly.
46 | pub const ALPN: &str = message::Alpn::CURRENT.0;
47 |
48 | /// Export the web_transport crate.
49 | pub use web_transport;
50 |
--------------------------------------------------------------------------------
/rs/moq/src/message/announce.rs:
--------------------------------------------------------------------------------
1 | use num_enum::{IntoPrimitive, TryFromPrimitive};
2 |
3 | use crate::coding::*;
4 |
5 | /// Sent by the publisher to announce the availability of a track.
6 | /// The payload contains the contents of the wildcard.
7 | #[derive(Clone, Debug, PartialEq, Eq)]
8 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9 | pub enum Announce {
10 | Active { suffix: String },
11 | Ended { suffix: String },
12 | }
13 |
14 | impl Announce {
15 | pub fn suffix(&self) -> &str {
16 | match self {
17 | Announce::Active { suffix } => suffix,
18 | Announce::Ended { suffix } => suffix,
19 | }
20 | }
21 | }
22 |
23 | impl Decode for Announce {
24 | fn decode(r: &mut R) -> Result {
25 | Ok(match AnnounceStatus::decode(r)? {
26 | AnnounceStatus::Active => Self::Active {
27 | suffix: String::decode(r)?,
28 | },
29 | AnnounceStatus::Ended => Self::Ended {
30 | suffix: String::decode(r)?,
31 | },
32 | })
33 | }
34 | }
35 |
36 | impl Encode for Announce {
37 | fn encode(&self, w: &mut W) {
38 | match self {
39 | Self::Active { suffix } => {
40 | AnnounceStatus::Active.encode(w);
41 | suffix.encode(w);
42 | }
43 | Self::Ended { suffix } => {
44 | AnnounceStatus::Ended.encode(w);
45 | suffix.encode(w);
46 | }
47 | }
48 | }
49 | }
50 |
51 | /// Sent by the subscriber to request ANNOUNCE messages.
52 | #[derive(Clone, Debug)]
53 | pub struct AnnounceRequest {
54 | // Request tracks with this prefix.
55 | pub prefix: String,
56 | }
57 |
58 | impl Decode for AnnounceRequest {
59 | fn decode(r: &mut R) -> Result {
60 | let prefix = String::decode(r)?;
61 | Ok(Self { prefix })
62 | }
63 | }
64 |
65 | impl Encode for AnnounceRequest {
66 | fn encode(&self, w: &mut W) {
67 | self.prefix.encode(w)
68 | }
69 | }
70 |
71 | /// Send by the publisher, used to determine the message that follows.
72 | #[derive(Clone, Copy, Debug, IntoPrimitive, TryFromPrimitive)]
73 | #[repr(u8)]
74 | enum AnnounceStatus {
75 | Ended = 0,
76 | Active = 1,
77 | }
78 |
79 | impl Decode for AnnounceStatus {
80 | fn decode(r: &mut R) -> Result {
81 | let status = u8::decode(r)?;
82 | match status {
83 | 0 => Ok(Self::Ended),
84 | 1 => Ok(Self::Active),
85 | _ => Err(DecodeError::InvalidValue),
86 | }
87 | }
88 | }
89 |
90 | impl Encode for AnnounceStatus {
91 | fn encode(&self, w: &mut W) {
92 | (*self as u8).encode(w)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/rs/moq/src/message/extensions.rs:
--------------------------------------------------------------------------------
1 | use std::collections::HashMap;
2 | use std::io::Cursor;
3 |
4 | use crate::coding::*;
5 |
6 | pub trait Extension: Encode + Decode {
7 | fn id() -> u64;
8 | }
9 |
10 | #[derive(Default, Debug, Clone)]
11 | pub struct Extensions(HashMap>);
12 |
13 | impl Decode for Extensions {
14 | fn decode(mut r: &mut R) -> Result {
15 | let mut map = HashMap::new();
16 |
17 | // I hate this encoding so much; let me encode my role and get on with my life.
18 | let count = u64::decode(r)?;
19 | for _ in 0..count {
20 | let kind = u64::decode(r)?;
21 | if map.contains_key(&kind) {
22 | return Err(DecodeError::DupliateParameter);
23 | }
24 |
25 | let data = Vec::::decode(&mut r)?;
26 | map.insert(kind, data);
27 | }
28 |
29 | Ok(Extensions(map))
30 | }
31 | }
32 |
33 | impl Encode for Extensions {
34 | fn encode(&self, w: &mut W) {
35 | self.0.len().encode(w);
36 |
37 | for (kind, value) in self.0.iter() {
38 | kind.encode(w);
39 | value.encode(w);
40 | }
41 | }
42 | }
43 |
44 | impl Extensions {
45 | pub fn get(&self) -> Result, DecodeError> {
46 | Ok(match self.0.get(&E::id()) {
47 | Some(payload) => {
48 | let mut cursor = Cursor::new(payload);
49 | Some(E::decode(&mut cursor)?)
50 | }
51 | None => None,
52 | })
53 | }
54 |
55 | pub fn set(&mut self, e: E) {
56 | let mut value = Vec::new();
57 | e.encode(&mut value);
58 | self.0.insert(E::id(), value);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/rs/moq/src/message/frame.rs:
--------------------------------------------------------------------------------
1 | use crate::coding::*;
2 |
3 | #[derive(Clone, Debug)]
4 | pub struct Frame {
5 | pub size: u64,
6 | }
7 |
8 | impl Decode for Frame {
9 | fn decode(r: &mut R) -> Result {
10 | Ok(Self { size: u64::decode(r)? })
11 | }
12 | }
13 |
14 | impl Encode for Frame {
15 | fn encode(&self, w: &mut W) {
16 | self.size.encode(w);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/rs/moq/src/message/group.rs:
--------------------------------------------------------------------------------
1 | use crate::coding::*;
2 |
3 | #[derive(Clone, Debug)]
4 | pub struct Group {
5 | // The subscribe ID.
6 | pub subscribe: u64,
7 |
8 | // The group sequence number
9 | pub sequence: u64,
10 | }
11 |
12 | impl Decode for Group {
13 | fn decode(r: &mut R) -> Result {
14 | Ok(Self {
15 | subscribe: u64::decode(r)?,
16 | sequence: u64::decode(r)?,
17 | })
18 | }
19 | }
20 |
21 | impl Encode for Group {
22 | fn encode(&self, w: &mut W) {
23 | self.subscribe.encode(w);
24 | self.sequence.encode(w);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/rs/moq/src/message/mod.rs:
--------------------------------------------------------------------------------
1 | //! Low-level message sent over the wire, as defined in the specification.
2 | //!
3 | //! This module could be used directly but 99% of the time you should use the higher-level [crate::Session] API.
4 | mod announce;
5 | mod extensions;
6 | mod frame;
7 | mod group;
8 | mod session;
9 | mod setup;
10 | mod stream;
11 | mod subscribe;
12 | mod versions;
13 |
14 | pub use announce::*;
15 | pub use extensions::*;
16 | pub use frame::*;
17 | pub use group::*;
18 | pub use session::*;
19 | pub use setup::*;
20 | pub use stream::*;
21 | pub use subscribe::*;
22 | pub use versions::*;
23 |
--------------------------------------------------------------------------------
/rs/moq/src/message/session.rs:
--------------------------------------------------------------------------------
1 | use crate::coding::*;
2 |
3 | #[derive(Clone, Debug)]
4 | pub struct SessionInfo {
5 | pub bitrate: Option,
6 | }
7 |
8 | impl Decode for SessionInfo {
9 | fn decode(r: &mut R) -> Result {
10 | let bitrate = match u64::decode(r)? {
11 | 0 => None,
12 | bitrate => Some(bitrate),
13 | };
14 |
15 | Ok(Self { bitrate })
16 | }
17 | }
18 |
19 | impl Encode for SessionInfo {
20 | fn encode(&self, w: &mut W) {
21 | self.bitrate.unwrap_or(0).encode(w);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/rs/moq/src/message/setup.rs:
--------------------------------------------------------------------------------
1 | use super::{Extensions, Version, Versions};
2 | use crate::coding::*;
3 |
4 | /// Sent by the client to setup the session.
5 | #[derive(Debug, Clone)]
6 | pub struct ClientSetup {
7 | /// The list of supported versions in preferred order.
8 | pub versions: Versions,
9 |
10 | /// Extensions.
11 | pub extensions: Extensions,
12 | }
13 |
14 | impl Decode for ClientSetup {
15 | /// Decode a client setup message.
16 | fn decode(r: &mut R) -> Result {
17 | let versions = Versions::decode(r)?;
18 | let extensions = Extensions::decode(r)?;
19 |
20 | Ok(Self { versions, extensions })
21 | }
22 | }
23 |
24 | impl Encode for ClientSetup {
25 | /// Encode a server setup message.
26 | fn encode(&self, w: &mut W) {
27 | self.versions.encode(w);
28 | self.extensions.encode(w);
29 | }
30 | }
31 |
32 | /// Sent by the server in response to a client setup.
33 | #[derive(Debug, Clone)]
34 | pub struct ServerSetup {
35 | /// The list of supported versions in preferred order.
36 | pub version: Version,
37 |
38 | /// Supported extenisions.
39 | pub extensions: Extensions,
40 | }
41 |
42 | impl Decode for ServerSetup {
43 | /// Decode the server setup.
44 | fn decode(r: &mut R) -> Result {
45 | let version = Version::decode(r)?;
46 | let extensions = Extensions::decode(r)?;
47 |
48 | Ok(Self { version, extensions })
49 | }
50 | }
51 |
52 | impl Encode for ServerSetup {
53 | fn encode(&self, w: &mut W) {
54 | self.version.encode(w);
55 | self.extensions.encode(w);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/rs/moq/src/message/stream.rs:
--------------------------------------------------------------------------------
1 | use crate::coding::*;
2 |
3 | #[derive(Debug, PartialEq, Clone, Copy)]
4 | pub enum ControlType {
5 | Session,
6 | Announce,
7 | Subscribe,
8 | }
9 |
10 | impl Decode for ControlType {
11 | fn decode(r: &mut R) -> Result {
12 | let t = u64::decode(r)?;
13 | match t {
14 | 0 => Ok(Self::Session),
15 | 1 => Ok(Self::Announce),
16 | 2 => Ok(Self::Subscribe),
17 | _ => Err(DecodeError::InvalidValue),
18 | }
19 | }
20 | }
21 |
22 | impl Encode for ControlType {
23 | fn encode(&self, w: &mut W) {
24 | let v: u64 = match self {
25 | Self::Session => 0,
26 | Self::Announce => 1,
27 | Self::Subscribe => 2,
28 | };
29 | v.encode(w)
30 | }
31 | }
32 |
33 | #[derive(Debug, PartialEq, Clone, Copy)]
34 | pub enum DataType {
35 | Group,
36 | }
37 |
38 | impl Decode for DataType {
39 | fn decode(r: &mut R) -> Result {
40 | let t = u64::decode(r)?;
41 | match t {
42 | 0 => Ok(Self::Group),
43 | _ => Err(DecodeError::InvalidValue),
44 | }
45 | }
46 | }
47 |
48 | impl Encode for DataType {
49 | fn encode(&self, w: &mut W) {
50 | let v: u64 = match self {
51 | Self::Group => 0,
52 | };
53 | v.encode(w)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/rs/moq/src/message/subscribe.rs:
--------------------------------------------------------------------------------
1 | use crate::coding::{Decode, DecodeError, Encode};
2 |
3 | /// Sent by the subscriber to request all future objects for the given track.
4 | ///
5 | /// Objects will use the provided ID instead of the full track name, to save bytes.
6 | #[derive(Clone, Debug)]
7 | pub struct Subscribe {
8 | pub id: u64,
9 | pub broadcast: String,
10 | pub track: String,
11 | pub priority: i8,
12 | }
13 |
14 | impl Decode for Subscribe {
15 | fn decode(r: &mut R) -> Result {
16 | let id = u64::decode(r)?;
17 | let broadcast = String::decode(r)?;
18 | let track = String::decode(r)?;
19 | let priority = i8::decode(r)?;
20 |
21 | Ok(Self {
22 | id,
23 | broadcast,
24 | track,
25 | priority,
26 | })
27 | }
28 | }
29 |
30 | impl Encode for Subscribe {
31 | fn encode(&self, w: &mut W) {
32 | self.id.encode(w);
33 | self.broadcast.encode(w);
34 | self.track.encode(w);
35 | self.priority.encode(w);
36 | }
37 | }
38 |
39 | #[derive(Clone, Debug)]
40 | pub struct SubscribeOk {
41 | pub priority: i8,
42 | }
43 |
44 | impl Encode for SubscribeOk {
45 | fn encode(&self, w: &mut W) {
46 | self.priority.encode(w);
47 | }
48 | }
49 |
50 | impl Decode for SubscribeOk {
51 | fn decode(r: &mut R) -> Result {
52 | let priority = i8::decode(r)?;
53 | Ok(Self { priority })
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/rs/moq/src/model/mod.rs:
--------------------------------------------------------------------------------
1 | mod broadcast;
2 | mod frame;
3 | mod group;
4 | mod origin;
5 | mod track;
6 |
7 | pub use broadcast::*;
8 | pub use frame::*;
9 | pub use group::*;
10 | pub use origin::*;
11 | pub use track::*;
12 |
--------------------------------------------------------------------------------
/rs/moq/src/session/reader.rs:
--------------------------------------------------------------------------------
1 | use std::{cmp, fmt, io};
2 |
3 | use bytes::{Buf, Bytes, BytesMut};
4 |
5 | use crate::{coding::*, Error};
6 |
7 | pub struct Reader {
8 | stream: web_transport::RecvStream,
9 | buffer: BytesMut,
10 | }
11 |
12 | impl Reader {
13 | pub fn new(stream: web_transport::RecvStream) -> Self {
14 | Self {
15 | stream,
16 | buffer: Default::default(),
17 | }
18 | }
19 |
20 | pub async fn accept(session: &mut web_transport::Session) -> Result {
21 | let stream = session.accept_uni().await?;
22 | Ok(Self::new(stream))
23 | }
24 |
25 | pub async fn decode(&mut self) -> Result {
26 | loop {
27 | let mut cursor = io::Cursor::new(&self.buffer);
28 |
29 | // Try to decode with the current buffer.
30 | match T::decode(&mut cursor) {
31 | Ok(msg) => {
32 | self.buffer.advance(cursor.position() as usize);
33 | return Ok(msg);
34 | }
35 | Err(DecodeError::Short) => (), // Try again with more data
36 | Err(err) => return Err(err.into()),
37 | };
38 |
39 | if !self.buffer.is_empty() {
40 | tracing::trace!(buffer = ?self.buffer, "more data needed");
41 | }
42 |
43 | if self.stream.read_buf(&mut self.buffer).await?.is_none() {
44 | return Err(DecodeError::Short.into());
45 | }
46 | }
47 | }
48 |
49 | // Decode optional messages at the end of a stream
50 | pub async fn decode_maybe(&mut self) -> Result, Error> {
51 | match self.finished().await {
52 | Ok(()) => Ok(None),
53 | Err(Error::Decode(DecodeError::ExpectedEnd)) => Ok(Some(self.decode().await?)),
54 | Err(e) => Err(e),
55 | }
56 | }
57 |
58 | // Returns a non-zero chunk of data, or None if the stream is closed
59 | pub async fn read(&mut self, max: usize) -> Result , Error> {
60 | if !self.buffer.is_empty() {
61 | let size = cmp::min(max, self.buffer.len());
62 | let data = self.buffer.split_to(size).freeze();
63 | return Ok(Some(data));
64 | }
65 |
66 | Ok(self.stream.read(max).await?)
67 | }
68 |
69 | /// Wait until the stream is closed, erroring if there are any additional bytes.
70 | pub async fn finished(&mut self) -> Result<(), Error> {
71 | if self.buffer.is_empty() && self.stream.read_buf(&mut self.buffer).await?.is_none() {
72 | return Ok(());
73 | }
74 |
75 | Err(DecodeError::ExpectedEnd.into())
76 | }
77 |
78 | pub fn abort(&mut self, err: &Error) {
79 | self.stream.stop(err.to_code());
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/rs/moq/src/session/stream.rs:
--------------------------------------------------------------------------------
1 | use super::{Reader, Writer};
2 | use crate::{message, Error};
3 |
4 | pub(super) struct Stream {
5 | pub writer: Writer,
6 | pub reader: Reader,
7 | }
8 |
9 | impl Stream {
10 | pub async fn open(session: &mut web_transport::Session, typ: message::ControlType) -> Result {
11 | let (send, recv) = session.open_bi().await?;
12 |
13 | let mut writer = Writer::new(send);
14 | let reader = Reader::new(recv);
15 | writer.encode(&typ).await?;
16 |
17 | Ok(Stream { writer, reader })
18 | }
19 |
20 | pub async fn accept(session: &mut web_transport::Session) -> Result {
21 | let (send, recv) = session.accept_bi().await?;
22 |
23 | let writer = Writer::new(send);
24 | let reader = Reader::new(recv);
25 |
26 | Ok(Stream { writer, reader })
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/rs/moq/src/session/writer.rs:
--------------------------------------------------------------------------------
1 | use std::fmt;
2 |
3 | use crate::{coding::*, message, Error};
4 |
5 | // A wrapper around a web_transport::SendStream that will reset on Drop
6 | pub(super) struct Writer {
7 | stream: web_transport::SendStream,
8 | buffer: bytes::BytesMut,
9 | }
10 |
11 | impl Writer {
12 | pub fn new(stream: web_transport::SendStream) -> Self {
13 | Self {
14 | stream,
15 | buffer: Default::default(),
16 | }
17 | }
18 |
19 | pub async fn open(session: &mut web_transport::Session, typ: message::DataType) -> Result {
20 | let send = session.open_uni().await?;
21 |
22 | let mut writer = Self::new(send);
23 | writer.encode(&typ).await?;
24 |
25 | Ok(writer)
26 | }
27 |
28 | pub async fn encode(&mut self, msg: &T) -> Result<(), Error> {
29 | self.buffer.clear();
30 | msg.encode(&mut self.buffer);
31 |
32 | while !self.buffer.is_empty() {
33 | self.stream.write_buf(&mut self.buffer).await?;
34 | }
35 |
36 | Ok(())
37 | }
38 |
39 | pub async fn write(&mut self, buf: &[u8]) -> Result<(), Error> {
40 | self.stream.write(buf).await?; // convert the error type
41 | Ok(())
42 | }
43 |
44 | pub fn set_priority(&mut self, priority: i32) {
45 | self.stream.set_priority(priority);
46 | }
47 |
48 | /// A clean termination of the stream, waiting for the peer to close.
49 | pub async fn finish(&mut self) -> Result<(), Error> {
50 | self.stream.finish()?;
51 | self.stream.closed().await?; // TODO Return any error code?
52 | Ok(())
53 | }
54 |
55 | pub fn abort(&mut self, err: &Error) {
56 | self.stream.reset(err.to_code());
57 | }
58 |
59 | pub async fn closed(&mut self) -> Result<(), Error> {
60 | self.stream.closed().await?;
61 | Ok(())
62 | }
63 | }
64 |
65 | impl Drop for Writer {
66 | fn drop(&mut self) {
67 | // Unlike the Quinn default, we abort the stream on drop.
68 | self.stream.reset(Error::Cancel.to_code());
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/rs/rust-toolchain.toml:
--------------------------------------------------------------------------------
1 | [toolchain]
2 | components = ["rustfmt", "clippy"]
3 | targets = ["wasm32-unknown-unknown"]
4 |
--------------------------------------------------------------------------------